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..6c8b94188a2 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -2,7 +2,11 @@ import { spawn, spawnSync } from "node:child_process"; import { watch } from "node:fs"; 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(); @@ -79,7 +83,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", diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 8f20001bbb0..1fc956b39dc 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -307,6 +307,31 @@ function buildMacLauncher(electronBinaryPath) { return targetBinaryPath; } +function isLinuxSetuidSandboxConfigured(electronBinaryPath) { + if (process.platform !== "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(); @@ -320,6 +345,14 @@ export function resolveElectronPath() { return buildMacLauncher(electronBinaryPath); } +export function resolveElectronLaunchCommand(args = []) { + const electronPath = resolveElectronPath(); + return { + electronPath, + args: [...resolveLinuxSandboxArgs(electronPath), ...args], + }; +} + export function resolveDevProtocolClient() { if (process.platform !== "darwin" || !isDevelopment) { return null; 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/DesktopCloudAuthTokenStore.test.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts index 3257edca885..929664e05bf 100644 --- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts +++ b/apps/desktop/src/app/DesktopCloudAuthTokenStore.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopCloudAuthTokenStore from "./DesktopCloudAuthTokenStore.ts"; diff --git a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts index 652072c1f5d..8953f3e9737 100644 --- a/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts +++ b/apps/desktop/src/app/DesktopCloudAuthTokenStore.ts @@ -11,7 +11,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; interface CloudAuthTokenDocument { diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts new file mode 100644 index 00000000000..42d5db1259b --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -0,0 +1,123 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; + +import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts"; +import * as DesktopConfig from "./DesktopConfig.ts"; +import * as DesktopConnectionCatalogStore from "./DesktopConnectionCatalogStore.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref | null = null) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: Effect.succeed(available), + encryptString: (value) => Effect.succeed(textEncoder.encode(`encrypted:${value}`)), + decryptString: (value) => { + return Effect.gen(function* () { + const decoded = textDecoder.decode(value); + if ( + !decoded.startsWith("encrypted:") || + (failDecrypt !== null && (yield* Ref.get(failDecrypt))) + ) { + return yield* new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("invalid encrypted catalog"), + }); + } + return decoded.slice("encrypted:".length); + }); + }, + } satisfies ElectronSafeStorage.ElectronSafeStorageShape); +} + +function makeLayer( + baseDir: string, + encryptionAvailable = true, + failDecrypt: Ref.Ref | null = null, +) { + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "darwin", + processArch: "arm64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); + + return DesktopConnectionCatalogStore.layer.pipe( + Layer.provideMerge(environmentLayer), + Layer.provideMerge(makeSafeStorageLayer(encryptionAvailable, failDecrypt)), + Layer.provideMerge(NodeServices.layer), + ); +} + +const withStore = ( + effect: Effect.Effect, + encryptionAvailable = true, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + return yield* effect.pipe(Effect.provide(makeLayer(baseDir, encryptionAvailable))); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("DesktopConnectionCatalogStore", () => { + it.effect("persists, reads, and clears an encrypted connection catalog", () => + withStore( + Effect.gen(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalog = '{"schemaVersion":1,"targets":[]}'; + + assert.isTrue(yield* store.set(catalog)); + assert.deepStrictEqual(yield* store.get, Option.some(catalog)); + + yield* store.clear; + assert.deepStrictEqual(yield* store.get, Option.none()); + }), + ), + ); + + it.effect("does not persist when secure storage is unavailable", () => + withStore( + Effect.gen(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + assert.isFalse(yield* store.set("{}")); + assert.deepStrictEqual(yield* store.get, Option.none()); + }), + false, + ), + ); + + it.effect("discards a catalog that can no longer be decrypted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const failDecrypt = yield* Ref.make(false); + const layer = makeLayer(baseDir, true, failDecrypt); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(layer), + ); + + assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); + yield* Ref.set(failDecrypt, true); + assert.deepStrictEqual(yield* store.get, Option.none()); + assert.deepStrictEqual(yield* store.get, Option.none()); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts new file mode 100644 index 00000000000..f7d77aff055 --- /dev/null +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -0,0 +1,187 @@ +import { fromLenientJson } from "@t3tools/shared/schemaJson"; +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 FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const ConnectionCatalogDocument = Schema.Struct({ + version: Schema.Literal(1), + encryptedCatalog: Schema.String, +}); +type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; + +const ConnectionCatalogDocumentJson = fromLenientJson(ConnectionCatalogDocument); +const decodeConnectionCatalogDocumentJson = Schema.decodeEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocumentJson = Schema.encodeEffect(ConnectionCatalogDocumentJson); + +export class DesktopConnectionCatalogStoreWriteError extends Data.TaggedError( + "DesktopConnectionCatalogStoreWriteError", +)<{ + readonly cause: PlatformError.PlatformError | Schema.SchemaError; +}> { + override get message() { + return `Failed to write desktop connection catalog: ${this.cause.message}`; + } +} + +export class DesktopConnectionCatalogStoreDecodeError extends Data.TaggedError( + "DesktopConnectionCatalogStoreDecodeError", +)<{ + readonly cause: Encoding.EncodingError; +}> { + override get message() { + return "Failed to decode the desktop connection catalog."; + } +} + +export interface DesktopConnectionCatalogStoreShape { + readonly get: Effect.Effect< + Option.Option, + | DesktopConnectionCatalogStoreDecodeError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageDecryptError + >; + readonly set: ( + catalog: string, + ) => Effect.Effect< + boolean, + | DesktopConnectionCatalogStoreWriteError + | ElectronSafeStorage.ElectronSafeStorageAvailabilityError + | ElectronSafeStorage.ElectronSafeStorageEncryptError + >; + readonly clear: Effect.Effect; +} + +export class DesktopConnectionCatalogStore extends Context.Service< + DesktopConnectionCatalogStore, + DesktopConnectionCatalogStoreShape +>()("@t3tools/desktop/app/DesktopConnectionCatalogStore") {} + +function decodeSecretBytes( + encoded: string, +): Effect.Effect { + return Effect.fromResult(Encoding.decodeBase64(encoded)).pipe( + Effect.mapError((cause) => new DesktopConnectionCatalogStoreDecodeError({ cause })), + ); +} + +const readDocument = ( + fileSystem: FileSystem.FileSystem, + catalogPath: string, +): Effect.Effect> => + fileSystem.readFileString(catalogPath).pipe( + Effect.option, + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => decodeConnectionCatalogDocumentJson(raw).pipe(Effect.option), + }), + ), + ); + +const writeDocument = Effect.fn("desktop.connectionCatalogStore.writeDocument")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly catalogPath: string; + readonly document: ConnectionCatalogDocument; + readonly suffix: string; +}): Effect.fn.Return { + const directory = input.path.dirname(input.catalogPath); + const tempPath = `${input.catalogPath}.${process.pid}.${input.suffix}.tmp`; + const encoded = yield* encodeConnectionCatalogDocumentJson(input.document); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }); + yield* Effect.gen(function* () { + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`); + yield* input.fileSystem.rename(tempPath, input.catalogPath); + }).pipe( + Effect.ensuring( + input.fileSystem.remove(tempPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove a temporary connection catalog file.", { + tempPath, + error, + }), + ), + ), + ), + ); +}); + +export const layer = Layer.effect( + DesktopConnectionCatalogStore, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const crypto = yield* Crypto.Crypto; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); + + return DesktopConnectionCatalogStore.of({ + get: Effect.gen(function* () { + const document = yield* readDocument(fileSystem, catalogPath); + if (Option.isNone(document) || !(yield* safeStorage.isEncryptionAvailable)) { + return Option.none(); + } + return yield* decodeSecretBytes(document.value.encryptedCatalog).pipe( + Effect.flatMap(safeStorage.decryptString), + Effect.map(Option.some), + Effect.catchTags({ + DesktopConnectionCatalogStoreDecodeError: (error) => + Effect.logWarning("Discarding an unreadable desktop connection catalog.", { + error, + }).pipe( + Effect.andThen(fileSystem.remove(catalogPath, { force: true })), + Effect.catch(() => Effect.void), + Effect.as(Option.none()), + ), + ElectronSafeStorageDecryptError: (error) => + Effect.logWarning("Discarding an undecryptable desktop connection catalog.", { + error, + }).pipe( + Effect.andThen(fileSystem.remove(catalogPath, { force: true })), + Effect.catch(() => Effect.void), + Effect.as(Option.none()), + ), + }), + ); + }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), + set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { + if (!(yield* safeStorage.isEncryptionAvailable)) { + return false; + } + const encryptedCatalog = Encoding.encodeBase64(yield* safeStorage.encryptString(catalog)); + const suffix = (yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause })), + )).replace(/-/g, ""); + yield* writeDocument({ + fileSystem, + path, + catalogPath, + document: { version: 1, encryptedCatalog }, + suffix, + }).pipe(Effect.mapError((cause) => new DesktopConnectionCatalogStoreWriteError({ cause }))); + return true; + }), + clear: fileSystem.remove(catalogPath, { force: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear the desktop connection catalog.", { + catalogPath, + error, + }), + ), + Effect.withSpan("desktop.connectionCatalogStore.clear"), + ), + }); + }), +); 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 431e0d34d81..5a6be92ac11 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -49,6 +49,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; @@ -183,6 +184,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/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts index cffe1572410..50706923fb3 100644 --- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts @@ -121,7 +121,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd input.readMagicDnsName ?? readTailscaleStatus.pipe( Effect.map((status) => status.magicDnsName), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); const dnsName = input.statusJson === undefined diff --git a/apps/desktop/src/electron/ElectronSafeStorage.ts b/apps/desktop/src/electron/ElectronSafeStorage.ts index c7b46265887..d30ddd682e6 100644 --- a/apps/desktop/src/electron/ElectronSafeStorage.ts +++ b/apps/desktop/src/electron/ElectronSafeStorage.ts @@ -1,54 +1,16 @@ -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Electron from "electron"; -export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( - "ElectronSafeStorageAvailabilityError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron safe storage failed to check encryption availability."; - } -} - -export class ElectronSafeStorageEncryptError extends Data.TaggedError( - "ElectronSafeStorageEncryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron safe storage failed to encrypt a string."; - } -} - -export class ElectronSafeStorageDecryptError extends Data.TaggedError( - "ElectronSafeStorageDecryptError", -)<{ - readonly cause: unknown; -}> { - override get message() { - return "Electron safe storage failed to decrypt a string."; - } -} - -export interface ElectronSafeStorageShape { - readonly isEncryptionAvailable: Effect.Effect; - readonly encryptString: ( - value: string, - ) => Effect.Effect; - readonly decryptString: ( - value: Uint8Array, - ) => Effect.Effect; -} - -export class ElectronSafeStorage extends Context.Service< +import { ElectronSafeStorage, - ElectronSafeStorageShape ->()("@t3tools/desktop/electron/ElectronSafeStorage") {} + ElectronSafeStorageAvailabilityError, + ElectronSafeStorageDecryptError, + ElectronSafeStorageEncryptError, +} from "./ElectronSafeStorageService.ts"; + +export * from "./ElectronSafeStorageService.ts"; const make = ElectronSafeStorage.of({ isEncryptionAvailable: Effect.try({ diff --git a/apps/desktop/src/electron/ElectronSafeStorageService.ts b/apps/desktop/src/electron/ElectronSafeStorageService.ts new file mode 100644 index 00000000000..5d2a0861d41 --- /dev/null +++ b/apps/desktop/src/electron/ElectronSafeStorageService.ts @@ -0,0 +1,48 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import type * as Effect from "effect/Effect"; + +export class ElectronSafeStorageAvailabilityError extends Data.TaggedError( + "ElectronSafeStorageAvailabilityError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to check encryption availability."; + } +} + +export class ElectronSafeStorageEncryptError extends Data.TaggedError( + "ElectronSafeStorageEncryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to encrypt a string."; + } +} + +export class ElectronSafeStorageDecryptError extends Data.TaggedError( + "ElectronSafeStorageDecryptError", +)<{ + readonly cause: unknown; +}> { + override get message() { + return "Electron safe storage failed to decrypt a string."; + } +} + +export interface ElectronSafeStorageShape { + readonly isEncryptionAvailable: Effect.Effect; + readonly encryptString: ( + value: string, + ) => Effect.Effect; + readonly decryptString: ( + value: Uint8Array, + ) => Effect.Effect; +} + +export class ElectronSafeStorage extends Context.Service< + ElectronSafeStorage, + ElectronSafeStorageShape +>()("@t3tools/desktop/electron/ElectronSafeStorageService/ElectronSafeStorage") {} diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 40f84054878..63e1de8feb0 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -9,6 +9,11 @@ import { setCloudAuthToken, } from "./methods/cloudAuth.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { + clearConnectionCatalog, + getConnectionCatalog, + setConnectionCatalog, +} from "./methods/connectionCatalog.ts"; import { getSavedEnvironmentRegistry, getSavedEnvironmentSecret, @@ -48,9 +53,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); @@ -62,6 +69,9 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(getSavedEnvironmentSecret); yield* ipc.handle(setSavedEnvironmentSecret); yield* ipc.handle(removeSavedEnvironmentSecret); + yield* ipc.handle(getConnectionCatalog); + yield* ipc.handle(setConnectionCatalog); + yield* ipc.handle(clearConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(ensureSshEnvironment); @@ -92,4 +102,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..28fbf8b8ebe 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -25,6 +25,9 @@ export const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environ export const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; export const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; export const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; +export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; +export const CLEAR_CONNECTION_CATALOG_CHANNEL = "desktop:clear-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; export const DISCONNECT_SSH_ENVIRONMENT_CHANNEL = "desktop:disconnect-ssh-environment"; @@ -39,3 +42,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/cloudAuth.ts b/apps/desktop/src/ipc/methods/cloudAuth.ts index a5a7aacff79..9f6a964ac05 100644 --- a/apps/desktop/src/ipc/methods/cloudAuth.ts +++ b/apps/desktop/src/ipc/methods/cloudAuth.ts @@ -59,7 +59,7 @@ function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInpu const method = (input.method ?? "GET") as "GET" | "POST"; const headers = new Headers(input.headers); const response = yield* HttpClientRequest.make(method)(url).pipe( - HttpClientRequest.setHeaders(headers), + HttpClientRequest.setHeaders(Object.fromEntries(headers.entries())), input.body === undefined ? identity : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined), diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts new file mode 100644 index 00000000000..c779c554ffd --- /dev/null +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -0,0 +1,37 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopConnectionCatalogStore from "../../app/DesktopConnectionCatalogStore.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getConnectionCatalog = makeIpcMethod({ + channel: IpcChannels.GET_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(Schema.String), + handler: Effect.fn("desktop.ipc.connectionCatalog.get")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return Option.getOrNull(yield* store.get); + }), +}); + +export const setConnectionCatalog = makeIpcMethod({ + channel: IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, + payload: Schema.String, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.connectionCatalog.set")(function* (catalog) { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return yield* store.set(catalog); + }), +}); + +export const clearConnectionCatalog = makeIpcMethod({ + channel: IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.connectionCatalog.clear")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + yield* store.clear; + }), +}); 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/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 6eeaa3202d9..2f46b263b0f 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -1,11 +1,11 @@ import { bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, issueRemoteWebSocketTicket, RemoteEnvironmentAuthUndeclaredStatusError, type RemoteEnvironmentAuthError, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { EnvironmentAuthInvalidError, DesktopDiscoveredSshHostSchema, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9356eef441b..aaa77deddb7 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -28,6 +28,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopCloudAuth from "./app/DesktopCloudAuth.ts"; import * as DesktopCloudAuthTokenStore from "./app/DesktopCloudAuthTokenStore.ts"; +import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -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( @@ -114,6 +117,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopClientSettings.layer, DesktopSavedEnvironments.layer, DesktopCloudAuthTokenStore.layer, + DesktopConnectionCatalogStore.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); @@ -127,7 +131,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..6c44394291a 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"; @@ -47,6 +52,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, { environmentId, secret }), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(IpcChannels.REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), + setConnectionCatalog: (catalog) => + ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), + clearConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.CLEAR_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), ensureSshEnvironment: async (target, options) => unwrapEnsureSshEnvironmentResult( @@ -141,4 +150,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..ac32d74ec69 --- /dev/null +++ b/apps/desktop/src/preview/Manager.test.ts @@ -0,0 +1,374 @@ +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("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..f82741d908f --- /dev/null +++ b/apps/desktop/src/preview/Manager.ts @@ -0,0 +1,2301 @@ +/** + * 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 = () => 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/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index d1d37b96e11..69aeaded690 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts"; import * as DesktopSavedEnvironments from "./DesktopSavedEnvironments.ts"; const textDecoder = new TextDecoder(); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 531b50ba73b..10938134044 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -14,7 +14,7 @@ import * as Schema from "effect/Schema"; import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorageService.ts"; type PersistedSavedEnvironmentDesktopSsh = NonNullable< PersistedSavedEnvironmentRecord["desktopSsh"] diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 5e977de2dea..2d133517132 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", @@ -155,6 +167,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"), + }), ), ), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 35145cc1d53..f24485fd879 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; @@ -162,6 +165,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,6 +174,7 @@ 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; @@ -192,9 +197,25 @@ const make = Effect.gen(function* () { contextIsolation: true, nodeIntegration: false, sandbox: true, + webviewTag: true, }, }); + 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(); 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/clerk-theme.json b/apps/mobile/clerk-theme.json index 52941785f3e..119927a04d6 100644 --- a/apps/mobile/clerk-theme.json +++ b/apps/mobile/clerk-theme.json @@ -13,7 +13,7 @@ "neutral": "#F5F5F5", "border": "#E5E5EA", "ring": "#A3A3A3", - "muted": "#F5F5F5", + "muted": "#F2F2F7", "shadow": "#000000" }, "darkColors": { @@ -30,7 +30,7 @@ "neutral": "#1C1C1C", "border": "#2A2A2A", "ring": "#525252", - "muted": "#1C1C1C", + "muted": "#0E0E0E", "shadow": "#000000" }, "design": { diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 4642879451a..0fbf4fb3c9d 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -18,7 +18,7 @@ --color-foreground: #262626; --color-foreground-secondary: #525252; --color-foreground-muted: #737373; - --color-foreground-tertiary: #a3a3a3; + --color-foreground-tertiary: #8e8e93; /* Borders & separators */ --color-border: rgba(0, 0, 0, 0.08); @@ -28,6 +28,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(0, 0, 0, 0.04); --color-subtle-strong: rgba(0, 0, 0, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #a21caf; /* Primary action */ --color-primary: #262626; @@ -58,6 +61,8 @@ /* Header / glass chrome */ --color-header: rgba(255, 255, 255, 0.97); --color-header-border: rgba(0, 0, 0, 0.06); + --color-glass-surface: rgba(255, 255, 255, 0.72); + --color-glass-tint: rgba(255, 255, 255, 0.18); /* StatusBar */ --color-status-bar: #f2f2f7; @@ -105,8 +110,8 @@ /* Text */ --color-foreground: #f5f5f5; --color-foreground-secondary: #a3a3a3; - --color-foreground-muted: #737373; - --color-foreground-tertiary: #525252; + --color-foreground-muted: #8e8e93; + --color-foreground-tertiary: #636366; /* Borders & separators */ --color-border: rgba(255, 255, 255, 0.06); @@ -116,6 +121,9 @@ /* Subtle backgrounds (badges, pills, overlays) */ --color-subtle: rgba(255, 255, 255, 0.04); --color-subtle-strong: rgba(255, 255, 255, 0.08); + --color-inline-skill-background: rgba(217, 70, 239, 0.12); + --color-inline-skill-border: rgba(217, 70, 239, 0.25); + --color-inline-skill-foreground: #f0abfc; /* Primary action */ --color-primary: #f5f5f5; @@ -136,16 +144,18 @@ /* Inputs */ --color-input: #141414; --color-input-border: rgba(255, 255, 255, 0.08); - --color-placeholder: #737373; + --color-placeholder: #8e8e93; /* Icons */ --color-icon: #f5f5f5; --color-icon-muted: #a3a3a3; - --color-icon-subtle: #737373; + --color-icon-subtle: #8e8e93; /* Header / glass chrome */ --color-header: rgba(10, 10, 10, 0.97); --color-header-border: rgba(255, 255, 255, 0.06); + --color-glass-surface: rgba(23, 23, 23, 0.78); + --color-glass-tint: rgba(23, 23, 23, 0.24); /* StatusBar */ --color-status-bar: #0a0a0a; diff --git a/apps/mobile/modules/t3-composer-editor/LICENSE b/apps/mobile/modules/t3-composer-editor/LICENSE new file mode 100644 index 00000000000..30b20e3b5f0 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-composer-editor/expo-module.config.json b/apps/mobile/modules/t3-composer-editor/expo-module.config.json new file mode 100644 index 00000000000..0d6384cd91a --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["T3ComposerEditorModule"] + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec new file mode 100644 index 00000000000..57c09fa9535 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditor.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'T3ComposerEditor' + s.version = '1.0.0' + s.summary = 'Native attributed composer editor for T3 Code mobile.' + s.description = 'UIKit-backed rich text composer with atomic skill and file tokens.' + s.author = 'T3 Tools' + s.homepage = 'https://t3tools.com' + s.platforms = { + :ios => '16.4', + } + s.source = { :path => '.' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift new file mode 100644 index 00000000000..5d3b33094cb --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -0,0 +1,71 @@ +import ExpoModulesCore + +public class T3ComposerEditorModule: Module { + public func definition() -> ModuleDefinition { + Name("T3ComposerEditor") + + View(T3ComposerEditorView.self) { + Prop("value") { (view: T3ComposerEditorView, value: String) in + view.setValue(value) + } + Prop("tokensJson") { (view: T3ComposerEditorView, tokensJson: String) in + view.setTokensJson(tokensJson) + } + Prop("selectionJson") { (view: T3ComposerEditorView, selectionJson: String) in + view.setSelectionJson(selectionJson) + } + Prop("themeJson") { (view: T3ComposerEditorView, themeJson: String) in + view.setThemeJson(themeJson) + } + Prop("placeholder") { (view: T3ComposerEditorView, placeholder: String) in + view.setPlaceholder(placeholder) + } + Prop("fontFamily") { (view: T3ComposerEditorView, fontFamily: String) in + view.setFontFamily(fontFamily) + } + Prop("fontSize") { (view: T3ComposerEditorView, fontSize: Double) in + view.setFontSize(CGFloat(fontSize)) + } + Prop("lineHeight") { (view: T3ComposerEditorView, lineHeight: Double) in + view.setLineHeight(CGFloat(lineHeight)) + } + Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in + view.setContentInsetVertical(CGFloat(contentInsetVertical)) + } + Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in + view.setEditable(editable) + } + Prop("scrollEnabled") { (view: T3ComposerEditorView, scrollEnabled: Bool) in + view.setScrollEnabled(scrollEnabled) + } + Prop("autoFocus") { (view: T3ComposerEditorView, autoFocus: Bool) in + view.setAutoFocus(autoFocus) + } + Prop("autoCorrect") { (view: T3ComposerEditorView, autoCorrect: Bool) in + view.setAutoCorrect(autoCorrect) + } + Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in + view.setSpellCheck(spellCheck) + } + + Events( + "onComposerChange", + "onComposerSelectionChange", + "onComposerFocus", + "onComposerBlur", + "onComposerPasteImages", + "onComposerContentSizeChange" + ) + + AsyncFunction("focus") { (view: T3ComposerEditorView) in + view.focusEditor() + } + AsyncFunction("blur") { (view: T3ComposerEditorView) in + view.blurEditor() + } + AsyncFunction("setSelection") { (view: T3ComposerEditorView, start: Int, end: Int) in + view.setSelection(start: start, end: end) + } + } + } +} diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift new file mode 100644 index 00000000000..1c599a14939 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -0,0 +1,790 @@ +import ExpoModulesCore +import UIKit + +private struct ComposerTokenPayload: Decodable { + let type: String + let source: String + let label: String + let iconUri: String? + let start: Int + let end: Int +} + +private struct ComposerSelectionPayload: Decodable { + let start: Int + let end: Int +} + +private struct ComposerThemePayload: Decodable { + let text: String + let placeholder: String + let chipBackground: String + let chipBorder: String + let chipText: String + let skillBackground: String + let skillBorder: String + let skillText: String + let fileTint: String +} + +private struct ComposerChipStyle { + let tint: UIColor + let backgroundColor: UIColor + let borderColor: UIColor + let textColor: UIColor +} + +private final class ComposerTextAttachment: NSTextAttachment { + let source: String + + init(source: String, image: UIImage, size: CGSize, baselineOffset: CGFloat) { + self.source = source + super.init(data: nil, ofType: nil) + self.image = image + bounds = CGRect(x: 0, y: baselineOffset, width: size.width, height: size.height) + } + + required init?(coder: NSCoder) { + nil + } +} + +private final class ComposerTextView: UITextView { + var onPasteImages: (([String]) -> Void)? + var onAttributedMutation: (() -> Void)? + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(paste(_:)) { + let pasteboard = UIPasteboard.general + if pasteboard.hasImages || + pasteboard.itemProviders.contains(where: { + $0.canLoadObject(ofClass: UIImage.self) + }) { + return true + } + } + return super.canPerformAction(action, withSender: sender) + } + + override func paste(_ sender: Any?) { + let pasteboard = UIPasteboard.general + let imageProviders = pasteboard.itemProviders.filter { + $0.canLoadObject(ofClass: UIImage.self) + } + if !imageProviders.isEmpty { + loadImages(from: imageProviders) + return + } + + let images = pasteboard.images ?? [] + if !images.isEmpty { + let urls = images.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + onPasteImages?(urls) + return + } + } + super.paste(sender) + } + + override func deleteBackward() { + guard selectedRange.length == 0, selectedRange.location > 0 else { + super.deleteBackward() + return + } + + let previousOffset = selectedRange.location - 1 + if textStorage.attribute(.attachment, at: previousOffset, effectiveRange: nil) + is ComposerTextAttachment { + replaceDisplayRange(NSRange(location: previousOffset, length: 1)) + return + } + + super.deleteBackward() + } + + private func replaceDisplayRange(_ range: NSRange) { + guard let start = position(from: beginningOfDocument, offset: range.location), + let end = position(from: start, offset: range.length), + let textRange = textRange(from: start, to: end) else { + return + } + replace(textRange, withText: "") + } + + private func loadImages(from providers: [NSItemProvider]) { + let group = DispatchGroup() + let lock = NSLock() + var images = [UIImage?](repeating: nil, count: providers.count) + + for (index, provider) in providers.enumerated() { + group.enter() + provider.loadObject(ofClass: UIImage.self) { object, _ in + defer { group.leave() } + guard let image = object as? UIImage else { + return + } + lock.lock() + images[index] = image + lock.unlock() + } + } + + group.notify(queue: .main) { [weak self] in + let urls = images.compactMap { $0 }.compactMap(Self.writeTemporaryImage) + if !urls.isEmpty { + self?.onPasteImages?(urls) + } + } + } + + override func copy(_ sender: Any?) { + guard selectedRange.length > 0 else { + return super.copy(sender) + } + UIPasteboard.general.string = serializedText(in: selectedRange) + } + + override func cut(_ sender: Any?) { + guard isEditable, selectedRange.length > 0 else { + return super.cut(sender) + } + copy(sender) + textStorage.replaceCharacters(in: selectedRange, with: "") + selectedRange = NSRange(location: selectedRange.location, length: 0) + onAttributedMutation?() + } + + func serializedText() -> String { + serializedText(in: NSRange(location: 0, length: attributedText.length)) + } + + func serializedText(in range: NSRange) -> String { + guard range.length > 0 else { + return "" + } + + let source = NSMutableString() + let nsString = attributedText.string as NSString + var cursor = range.location + let end = NSMaxRange(range) + attributedText.enumerateAttribute(.attachment, in: range) { value, attachmentRange, _ in + if attachmentRange.location > cursor { + source.append( + nsString.substring( + with: NSRange(location: cursor, length: attachmentRange.location - cursor) + ) + ) + } + if let attachment = value as? ComposerTextAttachment { + source.append(attachment.source) + } else { + source.append(nsString.substring(with: attachmentRange)) + } + cursor = NSMaxRange(attachmentRange) + } + if cursor < end { + source.append(nsString.substring(with: NSRange(location: cursor, length: end - cursor))) + } + return source as String + } + + func sourceOffset(forDisplayOffset displayOffset: Int) -> Int { + let boundedOffset = max(0, min(attributedText.length, displayOffset)) + if boundedOffset == 0 { + return 0 + } + + var sourceOffset = 0 + let range = NSRange(location: 0, length: boundedOffset) + attributedText.enumerateAttribute(.attachment, in: range) { value, attributeRange, _ in + if let attachment = value as? ComposerTextAttachment { + sourceOffset += (attachment.source as NSString).length + } else { + sourceOffset += attributeRange.length + } + } + return sourceOffset + } + + private static func writeTemporaryImage(_ image: UIImage) -> String? { + guard let data = image.pngData() else { + return nil + } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("t3-composer-paste", isDirectory: true) + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let url = directory.appendingPathComponent("\(UUID().uuidString).png") + try data.write(to: url, options: .atomic) + return url.absoluteString + } catch { + return nil + } + } +} + +public final class T3ComposerEditorView: ExpoView, UITextViewDelegate { + private let textView = ComposerTextView() + private let placeholderLabel = UILabel() + private var value = "" + private var tokensJson = "[]" + private var tokens: [ComposerTokenPayload] = [] + private var requestedSelection: ComposerSelectionPayload? + private var theme = ComposerThemePayload( + text: "#262626", + placeholder: "#8e8e93", + chipBackground: "#f2f2f7", + chipBorder: "#dedee3", + chipText: "#262626", + skillBackground: "#f9e8fb", + skillBorder: "#e5a6eb", + skillText: "#a21caf", + fileTint: "#737373" + ) + private var fontFamily = "DMSans_400Regular" + private var fontSize: CGFloat = 15 + private var lineHeight: CGFloat = 22 + private var contentInsetVertical: CGFloat = 0 + private var shouldAutoFocus = false + private var didAutoFocus = false + private var isApplyingControlledValue = false + private var lastContentSize = CGSize.zero + private var iconImages: [String: UIImage] = [:] + private var pendingIconUris = Set() + private var tokensNeedRebuild = false + + let onComposerChange = EventDispatcher() + let onComposerSelectionChange = EventDispatcher() + let onComposerFocus = EventDispatcher() + let onComposerBlur = EventDispatcher() + let onComposerPasteImages = EventDispatcher() + let onComposerContentSizeChange = EventDispatcher() + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) + + clipsToBounds = false + textView.delegate = self + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.keyboardDismissMode = .interactive + textView.alwaysBounceVertical = false + textView.showsVerticalScrollIndicator = true + textView.adjustsFontForContentSizeCategory = true + textView.onPasteImages = { [weak self] urls in + self?.onComposerPasteImages(["uris": urls]) + } + textView.onAttributedMutation = { [weak self] in + self?.emitTextChange() + } + addSubview(textView) + + placeholderLabel.numberOfLines = 0 + placeholderLabel.adjustsFontForContentSizeCategory = true + addSubview(placeholderLabel) + applyTypography() + applyTheme() + } + + public override func layoutSubviews() { + super.layoutSubviews() + textView.frame = bounds + let placeholderX = textView.textContainerInset.left + textView.textContainer.lineFragmentPadding + let placeholderY = textView.textContainerInset.top + let placeholderWidth = max( + 0, + bounds.width - placeholderX - textView.textContainerInset.right - + textView.textContainer.lineFragmentPadding + ) + placeholderLabel.frame = CGRect( + x: placeholderX, + y: placeholderY, + width: placeholderWidth, + height: max(lineHeight, placeholderLabel.font.lineHeight) + ) + emitContentSizeIfNeeded() + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + guard window != nil, shouldAutoFocus, !didAutoFocus else { + return + } + didAutoFocus = true + DispatchQueue.main.async { [weak self] in + self?.textView.becomeFirstResponder() + } + } + + func setValue(_ value: String) { + self.value = value + applyControlledDocument(force: tokensNeedRebuild) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setTokensJson(_ tokensJson: String) { + guard self.tokensJson != tokensJson else { + return + } + self.tokensJson = tokensJson + tokens = decode([ComposerTokenPayload].self, from: tokensJson) ?? [] + tokensNeedRebuild = true + applyControlledDocument(force: true) + if tokensMatchCurrentValue() { + tokensNeedRebuild = false + } + } + + func setSelectionJson(_ selectionJson: String) { + requestedSelection = decode(ComposerSelectionPayload.self, from: selectionJson) + applyRequestedSelection() + } + + func setThemeJson(_ themeJson: String) { + guard let nextTheme = decode(ComposerThemePayload.self, from: themeJson) else { + return + } + theme = nextTheme + applyTheme() + applyControlledDocument(force: true) + } + + func setPlaceholder(_ placeholder: String) { + placeholderLabel.text = placeholder + setNeedsLayout() + } + + func setFontFamily(_ fontFamily: String) { + self.fontFamily = fontFamily + applyTypography() + applyControlledDocument(force: true) + } + + func setFontSize(_ fontSize: CGFloat) { + self.fontSize = fontSize + applyTypography() + applyControlledDocument(force: true) + } + + func setLineHeight(_ lineHeight: CGFloat) { + self.lineHeight = lineHeight + applyTypography() + applyControlledDocument(force: true) + } + + func setContentInsetVertical(_ contentInsetVertical: CGFloat) { + self.contentInsetVertical = contentInsetVertical + textView.textContainerInset = UIEdgeInsets( + top: contentInsetVertical, + left: 0, + bottom: contentInsetVertical, + right: 0 + ) + setNeedsLayout() + } + + func setEditable(_ editable: Bool) { + textView.isEditable = editable + } + + func setScrollEnabled(_ scrollEnabled: Bool) { + textView.isScrollEnabled = scrollEnabled + } + + func setAutoFocus(_ autoFocus: Bool) { + shouldAutoFocus = autoFocus + } + + func setAutoCorrect(_ autoCorrect: Bool) { + textView.autocorrectionType = autoCorrect ? .yes : .no + } + + func setSpellCheck(_ spellCheck: Bool) { + textView.spellCheckingType = spellCheck ? .yes : .no + } + + func focusEditor() { + textView.becomeFirstResponder() + } + + func blurEditor() { + textView.resignFirstResponder() + } + + func setSelection(start: Int, end: Int) { + requestedSelection = ComposerSelectionPayload(start: start, end: end) + applyRequestedSelection() + } + + public func textViewDidChange(_ textView: UITextView) { + emitTextChange() + } + + public func textViewDidChangeSelection(_ textView: UITextView) { + guard !isApplyingControlledValue else { + return + } + emitSelection() + } + + public func textViewDidBeginEditing(_ textView: UITextView) { + onComposerFocus() + } + + public func textViewDidEndEditing(_ textView: UITextView) { + onComposerBlur() + } + + private func applyControlledDocument(force: Bool = false) { + let currentSource = textView.serializedText() + guard force || currentSource != value || !documentMatchesExpectedTokens() else { + updatePlaceholderVisibility() + return + } + + let previousSelection = sourceSelection() + isApplyingControlledValue = true + textView.attributedText = makeAttributedDocument() + let targetSelection = requestedSelection ?? previousSelection + requestedSelection = nil + textView.selectedRange = displayRange(for: targetSelection) + isApplyingControlledValue = false + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func makeAttributedDocument() -> NSAttributedString { + let result = NSMutableAttributedString() + let source = value as NSString + var cursor = 0 + let validTokens = tokens.filter { + $0.start >= cursor && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + + for token in validTokens { + if token.start < cursor { + continue + } + if token.start > cursor { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: token.start - cursor)), + to: result + ) + } + result.append(makeAttachmentString(token)) + cursor = token.end + } + if cursor < source.length { + appendPlainText( + source.substring(with: NSRange(location: cursor, length: source.length - cursor)), + to: result + ) + } + return result + } + + private func appendPlainText(_ text: String, to result: NSMutableAttributedString) { + result.append(NSAttributedString(string: text, attributes: baseAttributes())) + } + + private func makeAttachmentString(_ token: ComposerTokenPayload) -> NSAttributedString { + let isSkill = token.type == "skill" + let tint = UIColor(composerHex: isSkill ? theme.skillText : theme.fileTint) ?? .secondaryLabel + let iconName = isSkill ? "cube" : "doc" + let iconImage = token.iconUri.flatMap(iconImage(for:)) + let style = ComposerChipStyle( + tint: tint, + backgroundColor: UIColor( + composerHex: isSkill ? theme.skillBackground : theme.chipBackground + ) ?? .secondarySystemFill, + borderColor: UIColor( + composerHex: isSkill ? theme.skillBorder : theme.chipBorder + ) ?? .separator, + textColor: UIColor(composerHex: isSkill ? theme.skillText : theme.chipText) ?? .label + ) + let image = renderChip( + label: token.label, + iconName: iconName, + iconImage: iconImage, + style: style + ) + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let baselineOffset = floor((font.capHeight - image.size.height) / 2) + let attachment = ComposerTextAttachment( + source: token.source, + image: image, + size: image.size, + baselineOffset: baselineOffset + ) + return NSAttributedString(attachment: attachment) + } + + private func renderChip( + label: String, + iconName: String, + iconImage: UIImage?, + style: ComposerChipStyle + ) -> UIImage { + let font = UIFont(name: "DMSans_500Medium", size: max(12, fontSize - 2)) + ?? UIFont.systemFont(ofSize: max(12, fontSize - 2), weight: .medium) + let fallbackIcon = UIImage( + systemName: iconName, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium) + ) + let icon = iconImage ?? fallbackIcon + let textSize = (label as NSString).size(withAttributes: [.font: font]) + let iconWidth = icon == nil ? 0 : 14 + let iconGap = icon == nil ? 0 : 5 + let height: CGFloat = 24 + let width = ceil(9 + CGFloat(iconWidth + iconGap) + textSize.width + 9) + let format = UIGraphicsImageRendererFormat.preferred() + format.opaque = false + let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format) + return renderer.image { context in + let rect = CGRect(origin: .zero, size: CGSize(width: width, height: height)) + let path = UIBezierPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 7) + style.backgroundColor.setFill() + path.fill() + style.borderColor.setStroke() + path.lineWidth = 1 + path.stroke() + + var x: CGFloat = 9 + if let icon { + let renderedIcon = iconImage == nil + ? icon.withTintColor(style.tint, renderingMode: .alwaysOriginal) + : icon + renderedIcon.draw( + in: CGRect(x: x, y: 5, width: 14, height: 14) + ) + x += 19 + } + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .left + (label as NSString).draw( + in: CGRect(x: x, y: 3, width: textSize.width + 1, height: 18), + withAttributes: [ + .font: font, + .foregroundColor: style.textColor, + .paragraphStyle: paragraph, + ] + ) + context.cgContext.setAllowsAntialiasing(true) + } + } + + private func iconImage(for uri: String) -> UIImage? { + if let image = iconImages[uri] { + return image + } + guard !pendingIconUris.contains(uri), let url = URL(string: uri) else { + return nil + } + + if url.isFileURL, let image = UIImage(contentsOfFile: url.path) { + iconImages[uri] = image + return image + } + + pendingIconUris.insert(uri) + URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard let self, let data, let image = UIImage(data: data) else { + DispatchQueue.main.async { + self?.pendingIconUris.remove(uri) + } + return + } + DispatchQueue.main.async { + self.pendingIconUris.remove(uri) + self.iconImages[uri] = image + self.applyControlledDocument(force: true) + } + }.resume() + return nil + } + + private func baseAttributes() -> [NSAttributedString.Key: Any] { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + let paragraph = NSMutableParagraphStyle() + paragraph.minimumLineHeight = lineHeight + paragraph.maximumLineHeight = lineHeight + return [ + .font: font, + .foregroundColor: UIColor(composerHex: theme.text) ?? .label, + .paragraphStyle: paragraph, + ] + } + + private func applyTypography() { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) + textView.font = font + textView.typingAttributes = baseAttributes() + placeholderLabel.font = font + setNeedsLayout() + } + + private func applyTheme() { + textView.textColor = UIColor(composerHex: theme.text) ?? .label + placeholderLabel.textColor = UIColor(composerHex: theme.placeholder) ?? .placeholderText + tintColor = UIColor.systemBlue + } + + private func emitTextChange() { + guard !isApplyingControlledValue else { + return + } + value = textView.serializedText() + let selection = sourceSelection() + onComposerChange([ + "value": value, + "selection": ["start": selection.start, "end": selection.end], + ]) + updatePlaceholderVisibility() + emitContentSizeIfNeeded() + } + + private func emitSelection() { + let selection = sourceSelection() + onComposerSelectionChange([ + "selection": ["start": selection.start, "end": selection.end], + ]) + } + + private func sourceSelection() -> ComposerSelectionPayload { + ComposerSelectionPayload( + start: textView.sourceOffset(forDisplayOffset: textView.selectedRange.location), + end: textView.sourceOffset(forDisplayOffset: NSMaxRange(textView.selectedRange)) + ) + } + + private func displayRange(for selection: ComposerSelectionPayload) -> NSRange { + let start = displayOffset(forSourceOffset: selection.start) + let end = displayOffset(forSourceOffset: selection.end) + return NSRange(location: start, length: max(0, end - start)) + } + + private func displayOffset(forSourceOffset sourceOffset: Int) -> Int { + let boundedOffset = max(0, min((value as NSString).length, sourceOffset)) + var collapsedLength = 0 + for token in tokens where token.end <= boundedOffset { + collapsedLength += max(0, token.end - token.start - 1) + } + if let token = tokens.first(where: { $0.start < boundedOffset && boundedOffset < $0.end }) { + return token.start - collapsedLength + 1 + } + return boundedOffset - collapsedLength + } + + private func applyRequestedSelection() { + guard let requestedSelection else { + return + } + let nextRange = displayRange(for: requestedSelection) + guard nextRange.location <= textView.attributedText.length, + NSMaxRange(nextRange) <= textView.attributedText.length else { + return + } + isApplyingControlledValue = true + textView.selectedRange = nextRange + isApplyingControlledValue = false + } + + private func updatePlaceholderVisibility() { + placeholderLabel.isHidden = !value.isEmpty + } + + private func emitContentSizeIfNeeded() { + let nextSize = textView.contentSize + guard abs(nextSize.width - lastContentSize.width) > 0.5 || + abs(nextSize.height - lastContentSize.height) > 0.5 else { + return + } + lastContentSize = nextSize + onComposerContentSizeChange(["width": nextSize.width, "height": nextSize.height]) + } + + private func decode(_ type: T.Type, from json: String) -> T? { + guard let data = json.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(type, from: data) + } + + private func tokensMatchCurrentValue() -> Bool { + let source = value as NSString + return tokens.allSatisfy { + $0.start >= 0 && + $0.end > $0.start && + $0.end <= source.length && + source.substring(with: NSRange(location: $0.start, length: $0.end - $0.start)) == $0.source + } + } + + private func documentMatchesExpectedTokens() -> Bool { + let source = value as NSString + let expectedSources = tokens.compactMap { token -> String? in + guard token.start >= 0, + token.end > token.start, + token.end <= source.length, + source.substring( + with: NSRange(location: token.start, length: token.end - token.start) + ) == token.source else { + return nil + } + return token.source + } + var renderedSources: [String] = [] + textView.attributedText.enumerateAttribute( + .attachment, + in: NSRange(location: 0, length: textView.attributedText.length) + ) { value, _, _ in + if let attachment = value as? ComposerTextAttachment { + renderedSources.append(attachment.source) + } + } + return renderedSources == expectedSources + } +} + +private extension UIColor { + convenience init?(composerHex hex: String?) { + guard var value = hex?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6 || value.count == 8, + let raw = UInt64(value, radix: 16) else { + return nil + } + if value.count == 8 { + self.init( + red: CGFloat((raw >> 24) & 0xff) / 255, + green: CGFloat((raw >> 16) & 0xff) / 255, + blue: CGFloat((raw >> 8) & 0xff) / 255, + alpha: CGFloat(raw & 0xff) / 255 + ) + } else { + self.init( + red: CGFloat((raw >> 16) & 0xff) / 255, + green: CGFloat((raw >> 8) & 0xff) / 255, + blue: CGFloat(raw & 0xff) / 255, + alpha: 1 + ) + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/LICENSE b/apps/mobile/modules/t3-markdown-text/LICENSE new file mode 100644 index 00000000000..9aa27cb649d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024-25 Bluesky PBC +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec new file mode 100644 index 00000000000..0ac471faf24 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/T3MarkdownText.podspec @@ -0,0 +1,25 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) +new_arch_enabled = ENV["RCT_NEW_ARCH_ENABLED"] == "1" + +Pod::Spec.new do |s| + s.name = "T3MarkdownText" + s.version = package["version"] + s.summary = "Native selectable markdown renderer for T3 Code mobile." + s.description = "Fabric-backed attributed text and markdown rendering primitives owned by T3 Code." + s.homepage = "https://t3tools.com" + s.license = { :type => "MIT", :file => "LICENSE" } + s.author = { "T3 Tools" => "hello@t3tools.com" } + s.platforms = { :ios => min_ios_version_supported } + s.source = { :path => "." } + s.source_files = "ios/**/*.{h,m,mm,cpp}" + + install_modules_dependencies(s) + + if ENV["USE_FRAMEWORKS"] != nil && new_arch_enabled + add_dependency(s, "React-FabricComponents", :additional_framework_paths => [ + "react/renderer/textlayoutmanager/platform/ios", + ]) + end +end diff --git a/apps/mobile/modules/t3-markdown-text/UPSTREAM.md b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md new file mode 100644 index 00000000000..0ddc7775a9e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/UPSTREAM.md @@ -0,0 +1,12 @@ +# Upstream Attribution + +The Fabric attributed-text component in this module originated from +[`bluesky-social/react-native-uitextview`](https://github.com/bluesky-social/react-native-uitextview), +version `2.2.0`, commit `addc08fea303608f070fe1eeba4bc075f181c4af`. + +The upstream project is Copyright (c) 2024-25 Bluesky PBC and licensed under +the MIT License included in this directory. + +T3 Code has substantially modified and renamed the implementation, integrated +its markdown renderer, and owns the resulting module going forward. This is not +an upstream package dependency or a compatibility fork. diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/default_file.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/default_file.png new file mode 100644 index 00000000000..320ffd62d10 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/default_file.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_agents.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_agents.png new file mode 100644 index 00000000000..7340a20c890 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_agents.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_c.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_c.png new file mode 100644 index 00000000000..1758e395005 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_c.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_cpp.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_cpp.png new file mode 100644 index 00000000000..f874d7cdbf5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_cpp.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_css.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_css.png new file mode 100644 index 00000000000..2be31d43728 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_css.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_go.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_go.png new file mode 100644 index 00000000000..8b73e7b5552 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_go.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_html.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_html.png new file mode 100644 index 00000000000..cbe084a90e0 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_html.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_java.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_java.png new file mode 100644 index 00000000000..c4bbfc1be1a Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_java.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_js.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_js.png new file mode 100644 index 00000000000..eb6d8cc0749 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_js.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_json.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_json.png new file mode 100644 index 00000000000..fe2905f79cd Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_json.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_kotlin.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_kotlin.png new file mode 100644 index 00000000000..5a6379e54b0 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_kotlin.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_markdown.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_markdown.png new file mode 100644 index 00000000000..a50ff234332 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_markdown.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_npm.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_npm.png new file mode 100644 index 00000000000..d39a378d5f5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_npm.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_python.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_python.png new file mode 100644 index 00000000000..237dba1efa3 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_python.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_reactts.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_reactts.png new file mode 100644 index 00000000000..2090a5f2e80 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_reactts.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_rust.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_rust.png new file mode 100644 index 00000000000..145e7f65537 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_rust.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_shell.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_shell.png new file mode 100644 index 00000000000..5d07a8b8cf7 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_shell.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_sql.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_sql.png new file mode 100644 index 00000000000..8226a4cc7ed Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_sql.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_swift.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_swift.png new file mode 100644 index 00000000000..3bf91571598 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_swift.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_toml.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_toml.png new file mode 100644 index 00000000000..b1deed7f341 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_toml.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_tsconfig.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_tsconfig.png new file mode 100644 index 00000000000..3688e031cf0 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_tsconfig.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_typescript.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_typescript.png new file mode 100644 index 00000000000..74e57a7273d Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_typescript.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_xml.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_xml.png new file mode 100644 index 00000000000..244740bdf63 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_xml.png differ diff --git a/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_yaml.png b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_yaml.png new file mode 100644 index 00000000000..b632e7c17b5 Binary files /dev/null and b/apps/mobile/modules/t3-markdown-text/assets/file-icons/file_type_yaml.png differ diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts new file mode 100644 index 00000000000..89bce5395c8 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/index.ts @@ -0,0 +1,27 @@ +export { markdownFileIconSource } from "./src/markdownFileIcons"; +export { + resolveMarkdownFileIcon, + resolveMarkdownLinkPresentation, + type MarkdownFileIcon, + type MarkdownLinkPresentation, +} from "./src/markdownLinks"; +export { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, + type NativeMarkdownDocumentChunk, + type NativeMarkdownTextRun, +} from "./src/nativeMarkdownText"; +export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive"; +export { + SelectableMarkdownText, + type MarkdownCodeHighlighter, + type MarkdownHighlightedToken, +} from "./src/SelectableMarkdownText"; +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./src/SelectableMarkdownText.types"; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h new file mode 100644 index 00000000000..f9c05a19819 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.h @@ -0,0 +1,13 @@ +#import +#import + +#ifndef T3MarkdownTextNativeComponent_h +#define T3MarkdownTextNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN +@interface T3MarkdownText : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm new file mode 100644 index 00000000000..7cf2d240214 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -0,0 +1,583 @@ +#import "T3MarkdownText.h" +#import "T3MarkdownTextShadowNode.h" +#import "T3MarkdownTextComponentDescriptor.h" +#import "T3MarkdownTextRun.h" +#import + +#import +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" + +using namespace facebook::react; + +static void T3MarkdownTextApplyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void T3MarkdownTextApplyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges, + NSDictionary *images) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + UIImage *image = images[imageUri]; + if ([imageUri hasPrefix:@"sf:"]) { + NSString *symbolName = [imageUri substringFromIndex:3]; + UIColor *foregroundColor = + [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil] ?: UIColor.labelColor; + image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; + } + attachment.image = image ?: [[UIImage alloc] init]; + attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const NSRange range = NSMakeRange( + attachmentRange.location, + MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +static NSArray *> *T3MarkdownTextExtractChipBackgrounds( + NSMutableAttributedString *attributedString, + const std::vector &chipRanges) +{ + NSMutableArray *> *backgrounds = [NSMutableArray array]; + for (const auto &chipRange : chipRanges) { + if (chipRange.length == 0 || chipRange.location >= attributedString.length) { + continue; + } + + const NSRange range = NSMakeRange( + chipRange.location, + MIN(chipRange.length, attributedString.length - chipRange.location)); + UIColor *color = [attributedString attribute:NSBackgroundColorAttributeName + atIndex:range.location + effectiveRange:nil]; + UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName + atIndex:range.location + effectiveRange:nil]; + if (color == nil) { + continue; + } + [backgrounds addObject:@{ + @"range": [NSValue valueWithRange:range], + @"color": color, + @"strokeColor": [foregroundColor + colorWithAlphaComponent:chipRange.isSkill ? 0.25 : 0.1] ?: UIColor.clearColor, + }]; + [attributedString removeAttribute:NSBackgroundColorAttributeName range:range]; + } + return backgrounds; +} + +@interface T3MarkdownTextBackingView : UITextView +@property(nonatomic, copy) NSArray *> *chipBackgrounds; +@end + +@implementation T3MarkdownTextBackingView + +- (void)drawRect:(CGRect)rect +{ + [self.layoutManager ensureLayoutForTextContainer:self.textContainer]; + CGContextRef context = UIGraphicsGetCurrentContext(); + if (context != nil) { + CGContextSaveGState(context); + CGContextResetClip(context); + CGContextClipToRect(context, self.bounds); + } + for (NSDictionary *background in self.chipBackgrounds) { + const NSRange characterRange = [background[@"range"] rangeValue]; + UIColor *color = background[@"color"]; + UIColor *strokeColor = background[@"strokeColor"]; + if (characterRange.length == 0 || NSMaxRange(characterRange) > self.textStorage.length) { + continue; + } + + const NSRange glyphRange = + [self.layoutManager glyphRangeForCharacterRange:characterRange actualCharacterRange:nil]; + [color setFill]; + [self.layoutManager + enumerateEnclosingRectsForGlyphRange:glyphRange + withinSelectedGlyphRange:NSMakeRange(NSNotFound, 0) + inTextContainer:self.textContainer + usingBlock:^(CGRect glyphRect, BOOL *stop) { + const CGFloat chipHeight = 22; + CGRect chipRect = CGRectMake( + glyphRect.origin.x - 4, + CGRectGetMidY(glyphRect) - chipHeight / 2, + glyphRect.size.width + 8, + chipHeight); + chipRect.origin.x += self.textContainerInset.left; + chipRect.origin.y += self.textContainerInset.top; + const CGFloat minimumX = self.textContainerInset.left + 0.5; + const CGFloat maximumX = + CGRectGetWidth(self.bounds) - self.textContainerInset.right - 0.5; + if (chipRect.origin.x < minimumX) { + chipRect.size.width -= minimumX - chipRect.origin.x; + chipRect.origin.x = minimumX; + } + if (CGRectGetMaxX(chipRect) > maximumX) { + chipRect.size.width = MAX(0, maximumX - chipRect.origin.x); + } + UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:chipRect cornerRadius:6]; + [path fill]; + [strokeColor setStroke]; + path.lineWidth = 1; + [path stroke]; + }]; + } + if (context != nil) { + CGContextRestoreGState(context); + } + + [super drawRect:rect]; +} + +@end + +@interface T3MarkdownText () + +@end + +@implementation T3MarkdownText{ + UIView * _view; + T3MarkdownTextBackingView * _textView; + T3MarkdownTextShadowNode::ConcreteState::Shared _state; + UITapGestureRecognizer * _outsideTapRecognizer; + BOOL _suppressSelectionChange; + NSMutableDictionary * _attachmentImages; + NSMutableSet * _pendingAttachmentUris; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _view = [[UIView alloc] init]; + self.contentView = _view; + self.clipsToBounds = true; + + _textView = [[T3MarkdownTextBackingView alloc] init]; + _attachmentImages = [[NSMutableDictionary alloc] init]; + _pendingAttachmentUris = [[NSMutableSet alloc] init]; + _textView.scrollEnabled = false; + _textView.editable = false; + _textView.textContainerInset = UIEdgeInsetsZero; + _textView.textContainer.lineFragmentPadding = 0; + _textView.delegate = self; + // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO. + _textView.layoutManager.usesFontLeading = NO; + [self addSubview:_textView]; + + const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self + action:@selector(handleLongPressIfNecessary:)]; + longPressGestureRecognizer.delegate = self; + + const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handlePressIfNecessary:)]; + pressGestureRecognizer.delegate = self; + [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer]; + + [_textView addGestureRecognizer:pressGestureRecognizer]; + [_textView addGestureRecognizer:longPressGestureRecognizer]; + + _outsideTapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self + action:@selector(handleOutsideTap:)]; + _outsideTapRecognizer.cancelsTouchesInView = NO; + _outsideTapRecognizer.delegate = self; + } + + return self; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (self.window) { + [self.window addGestureRecognizer:_outsideTapRecognizer]; + } else { + [_outsideTapRecognizer.view removeGestureRecognizer:_outsideTapRecognizer]; + } +} + +- (void)dealloc +{ + [_outsideTapRecognizer.view removeGestureRecognizer:_outsideTapRecognizer]; +} + +// See RCTParagraphComponentView +- (void)prepareForRecycle +{ + [super prepareForRecycle]; + _state.reset(); + + // Reset the frame to zero so that when it properly lays out on the next use + _textView.frame = CGRectZero; + _textView.attributedText = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + // _textView's frame is assigned inside drawRect, which only fires when + // state changes. Trigger a redraw whenever the host frame moves out from + // under it (rotation, parent relayout) so the text view resizes and + // onTextLayout re-fires with the new line wrapping. + if (!CGRectEqualToRect(_textView.frame, _view.frame)) { + [self setNeedsDisplay]; + } +} + +- (void)drawRect:(CGRect)rect +{ + if (!_state) { + return; + } + + const auto &props = *std::static_pointer_cast(_props); + + const auto attrString = _state->getData().attributedString; + NSMutableAttributedString *convertedAttrString = + [RCTNSAttributedStringFromAttributedString(attrString) mutableCopy]; + T3MarkdownTextApplyParagraphStyles( + convertedAttrString, + _state->getData().paragraphStyleRanges); + T3MarkdownTextApplyAttachments( + convertedAttrString, + _state->getData().attachmentRanges, + _attachmentImages); + _textView.chipBackgrounds = T3MarkdownTextExtractChipBackgrounds( + convertedAttrString, + _state->getData().chipRanges); + [self loadAttachmentImages:_state->getData().attachmentRanges]; + + // Setting attributedText clears any active text selection, and re-assigning + // the frame triggers a layout flush that has the same effect. Bail out + // entirely when nothing actually changed so a JS-side state update made in + // response to onSelectionChange doesn't deselect what the user is selecting. + const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString]; + const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame); + if (!textChanged && !frameChanged) { + return; + } + if (textChanged) { + // Reassigning attributedText clears any active selection. Save it and + // restore after, while suppressing the synthetic textViewDidChangeSelection + // events the clear-then-restore would otherwise produce — those would + // round-trip to JS and re-trigger this same path, causing a loop. + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = convertedAttrString; + if (savedRange.length > 0 && NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + } + if (frameChanged) { + _textView.frame = _view.frame; + } + + __block std::vector lines; + const int maxLines = props.numberOfLines; + [_textView.layoutManager enumerateLineFragmentsForGlyphRange:NSMakeRange(0, convertedAttrString.string.length) usingBlock:^(CGRect rect, + CGRect usedRect, + NSTextContainer * _Nonnull textContainer, + NSRange glyphRange, + BOOL * _Nonnull stop) { + const auto charRange = [self->_textView.layoutManager characterRangeForGlyphRange:glyphRange actualGlyphRange:nil]; + const auto line = [self->_textView.text substringWithRange:charRange]; + lines.push_back(line.UTF8String); + // enumerateLineFragments overshoots maximumNumberOfLines by one on iOS + // 18, so cap explicitly. + if (maxLines > 0 && lines.size() >= (size_t)maxLines) { + *stop = YES; + } + }]; + + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onTextLayout(facebook::react::T3MarkdownTextEventEmitter::OnTextLayout{static_cast(self.tag), lines}); + }; +} + +- (void)loadAttachmentImages:(const std::vector &)attachmentRanges +{ + for (const auto &attachmentRange : attachmentRanges) { + NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; + if ([imageUri hasPrefix:@"sf:"]) { + continue; + } + if (_attachmentImages[imageUri] != nil || [_pendingAttachmentUris containsObject:imageUri]) { + continue; + } + + NSURL *url = [NSURL URLWithString:imageUri]; + if (url == nil) { + continue; + } + if (url.isFileURL) { + UIImage *image = [UIImage imageWithContentsOfFile:url.path]; + if (image != nil) { + _attachmentImages[imageUri] = image; + dispatch_async(dispatch_get_main_queue(), ^{ + [self refreshDisplayedAttachments]; + }); + } + continue; + } + + [_pendingAttachmentUris addObject:imageUri]; + [[[NSURLSession sharedSession] dataTaskWithURL:url + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + UIImage *image = data == nil ? nil : [UIImage imageWithData:data]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_pendingAttachmentUris removeObject:imageUri]; + if (image != nil) { + self->_attachmentImages[imageUri] = image; + [self refreshDisplayedAttachments]; + } + }); + }] resume]; + } +} + +- (void)refreshDisplayedAttachments +{ + if (!_state || _textView.attributedText == nil) { + return; + } + + NSMutableAttributedString *attributedText = [_textView.attributedText mutableCopy]; + T3MarkdownTextApplyAttachments( + attributedText, + _state->getData().attachmentRanges, + _attachmentImages); + + const NSRange savedRange = _textView.selectedRange; + _suppressSelectionChange = YES; + _textView.attributedText = attributedText; + if (savedRange.location != NSNotFound && + NSMaxRange(savedRange) <= _textView.attributedText.length) { + _textView.selectedRange = savedRange; + } + _suppressSelectionChange = NO; + [_textView setNeedsDisplay]; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (oldViewProps.numberOfLines != newViewProps.numberOfLines) { + _textView.textContainer.maximumNumberOfLines = newViewProps.numberOfLines; + } + + if (oldViewProps.selectable != newViewProps.selectable) { + _textView.selectable = newViewProps.selectable; + } + + if (oldViewProps.allowFontScaling != newViewProps.allowFontScaling) { + if (@available(iOS 11.0, *)) { + _textView.adjustsFontForContentSizeCategory = newViewProps.allowFontScaling; + } + } + + if (oldViewProps.ellipsizeMode != newViewProps.ellipsizeMode) { + if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingHead; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingMiddle; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByTruncatingTail; + } else if (newViewProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Clip) { + _textView.textContainer.lineBreakMode = NSLineBreakMode::NSLineBreakByClipping; + } + } + + + // I'm not sure if this is really the right way to handle this style. This means that the entire _view_ the text + // is in will have this background color applied. To apply it just to a particular part of a string, you'd need + // to do Hello. + // This is how the base component works though, so we'll go with it for now. Can change later if we want. + if (oldViewProps.backgroundColor != newViewProps.backgroundColor) { + _textView.backgroundColor = RCTUIColorFromSharedColor(newViewProps.backgroundColor); + } + + [super updateProps:props oldProps:oldProps]; +} + +// See RCTParagraphComponentView +- (void)updateState:(const facebook::react::State::Shared &)state oldState:(const facebook::react::State::Shared &)oldState +{ + _state = std::static_pointer_cast(state); + [self setNeedsDisplay]; +} + +// MARK: - UIGestureRecognizerDelegate + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer +{ + return YES; +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch +{ + if (gestureRecognizer == _outsideTapRecognizer) { + UIWindow *window = touch.window; + if (!window) { + return NO; + } + UIView *hitView = [window hitTest:[touch locationInView:nil] withEvent:nil]; + return ![hitView isDescendantOfView:self]; + } + return YES; +} + +- (void)handleOutsideTap:(UITapGestureRecognizer *)sender +{ + // Defer past the current event loop turn so any in-flight edit-menu action + // (Copy / Define / Look Up / …) reads the live selection before we clear it. + UITextView *textView = _textView; + dispatch_async(dispatch_get_main_queue(), ^{ + UITextRange *range = textView.selectedTextRange; + if (range != nil && !range.isEmpty) { + textView.selectedTextRange = nil; + } + }); +} + +// MARK: - Touch handling + +- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender +{ + return [sender locationInView:_textView]; +} + +- (T3MarkdownTextRun*)getTouchChild:(CGPoint)location +{ + const auto charIndex = [_textView.layoutManager characterIndexForPoint:location + inTextContainer:_textView.textContainer + fractionOfDistanceBetweenInsertionPoints:nil + ]; + + int currIndex = -1; + for (UIView* child in self.subviews) { + if (![child isKindOfClass:[T3MarkdownTextRun class]]) { + continue; + } + + T3MarkdownTextRun* textChild = (T3MarkdownTextRun*)child; + + // This is UTF16 code units!! + currIndex += textChild.text.length; + + if (charIndex <= currIndex) { + return textChild; + } + } + + return nil; +} + +- (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onPress]; + } +} + +- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender +{ + const auto location = [self getLocationOfPress:sender]; + const auto child = [self getTouchChild:location]; + + if (child) { + [child onLongPress]; + } +} + +// MARK: - UITextViewDelegate + +- (void)textViewDidChangeSelection:(UITextView *)textView +{ + if (_suppressSelectionChange) { + return; + } + if (_eventEmitter == nullptr) { + return; + } + + const NSRange selectedRange = textView.selectedRange; + if (selectedRange.location == NSNotFound) { + return; + } + + // Fires on programmatic selection changes too (e.g. the outside-tap clear + // in handleOutsideTap:), so JS will see a synthetic empty-range event then. + std::dynamic_pointer_cast(_eventEmitter) + ->onSelectionChange(facebook::react::T3MarkdownTextEventEmitter::OnSelectionChange{ + static_cast(self.tag), + static_cast(selectedRange.location), + static_cast(selectedRange.location + selectedRange.length), + }); +} + +Class T3MarkdownTextCls(void) +{ + return T3MarkdownText.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h new file mode 100644 index 00000000000..77e21d58510 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm new file mode 100644 index 00000000000..3ca2b1eee5b --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextManager.mm @@ -0,0 +1,36 @@ +#import +#import +#import "RCTBridge.h" +#import "Utils.h" + +@interface T3MarkdownTextManager : RCTViewManager +@end + +@implementation T3MarkdownTextManager + +RCT_EXPORT_MODULE(T3MarkdownText) + +- (UIView *)view +{ + return [[UIView alloc] init]; +} + +RCT_CUSTOM_VIEW_PROPERTY(color, NSString, UIView) +{ +} + +@end + +@interface T3MarkdownTextRunManager : RCTViewManager +@end + +@implementation T3MarkdownTextRunManager + +RCT_EXPORT_MODULE(T3MarkdownTextRun) + +- (UIView *)view +{ + return nil; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h new file mode 100644 index 00000000000..b8b40657110 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h @@ -0,0 +1,24 @@ +// This guard prevent this file to be compiled in the old architecture. +#ifdef RCT_NEW_ARCH_ENABLED +#import +#import +#import + +#ifndef T3MarkdownTextRunNativeComponent_h +#define T3MarkdownTextRunNativeComponent_h + +NS_ASSUME_NONNULL_BEGIN + +@interface T3MarkdownTextRun : RCTViewComponentView + +@property (nonatomic, copy, nullable) NSString *text; + +- (void)onPress; +- (void)onLongPress; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* UitextviewViewNativeComponent_h */ +#endif /* RCT_NEW_ARCH_ENABLED */ diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm new file mode 100644 index 00000000000..4549084f03f --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm @@ -0,0 +1,72 @@ +#import "T3MarkdownTextRun.h" +#import "T3MarkdownText.h" +#import "T3MarkdownTextRunComponentDescriptor.h" +#import +#import +#import +#import "RCTFabricComponentsPlugins.h" +#import "Utils.h" + +using namespace facebook::react; + +@interface T3MarkdownTextRun () + +@end + +@implementation T3MarkdownTextRun { + NSString * _text; + RCTBubblingEventBlock _onPress; + RCTBubblingEventBlock _onLongPress; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + } + return self; +} + +- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps +{ + const auto &oldViewProps = *std::static_pointer_cast(_props); + const auto &newViewProps = *std::static_pointer_cast(props); + + if (newViewProps.text != oldViewProps.text) { + NSString *text = [NSString stringWithUTF8String:newViewProps.text.c_str()]; + _text = text; + } + + [super updateProps:props oldProps:oldProps]; +} + +- (void)onPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onPress(facebook::react::T3MarkdownTextRunEventEmitter::OnPress{}); + } +} + +- (void)onLongPress { + if (_eventEmitter != nullptr) { + std::dynamic_pointer_cast(_eventEmitter) + ->onLongPress(facebook::react::T3MarkdownTextRunEventEmitter::OnLongPress{}); + } +} + ++ (BOOL)shouldBeRecycled { + return NO; +} + +Class T3MarkdownTextRunCls(void) +{ + return T3MarkdownTextRun.class; +} + +@end diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h new file mode 100644 index 00000000000..61f9e1a129e --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunComponentDescriptor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "T3MarkdownTextRunShadowNode.h" + +#include +#include + +namespace facebook::react { +using T3MarkdownTextRunComponentDescriptor = ConcreteComponentDescriptor; + +void T3MarkdownTextRunSpec_registerComponentDescriptorsFromCodegen( + std::shared_ptr registry); +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp new file mode 100644 index 00000000000..a1af619205d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.cpp @@ -0,0 +1,6 @@ +#include "T3MarkdownTextRunShadowNode.h" + +namespace facebook::react { + +extern const char T3MarkdownTextRunComponentName[] = "T3MarkdownTextRun"; +} // namespace facebook::react diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h new file mode 100644 index 00000000000..c00bd1f2407 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRunShadowNode.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include + +namespace facebook::react { +extern const char T3MarkdownTextRunComponentName[]; + +using T3MarkdownTextRunShadowNode = ConcreteViewShadowNode< + T3MarkdownTextRunComponentName, + T3MarkdownTextRunProps, + T3MarkdownTextRunEventEmitter, + T3MarkdownTextRunState>; +} diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h new file mode 100644 index 00000000000..afc276aedda --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace facebook::react { + +extern const char T3MarkdownTextComponentName[]; + +struct T3MarkdownTextParagraphStyleRange { + size_t location; + size_t length; + Float firstLineHeadIndent; + Float headIndent; + Float paragraphSpacing; +}; + +struct T3MarkdownTextAttachmentRange { + size_t location; + size_t length; + std::string imageUri; +}; + +struct T3MarkdownTextChipRange { + size_t location; + size_t length; + bool isSkill; +}; + +class T3MarkdownTextStateReal final { + public: + AttributedString attributedString; + std::vector paragraphStyleRanges; + std::vector attachmentRanges; + std::vector chipRanges; +}; + +class T3MarkdownTextShadowNode final : public ConcreteViewShadowNode< +T3MarkdownTextComponentName, +T3MarkdownTextProps, +T3MarkdownTextEventEmitter, +T3MarkdownTextStateReal> { +public: + using ConcreteViewShadowNode::ConcreteViewShadowNode; + + T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment + ); + + static ShadowNodeTraits BaseTraits() { + auto traits = ConcreteViewShadowNode::BaseTraits(); + traits.set(ShadowNodeTraits::Trait::LeafYogaNode); + traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); + return traits; + } + + void layout(LayoutContext layoutContext) override; + + Size measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const override; + +private: + mutable AttributedString _attributedString; + mutable std::vector _paragraphStyleRanges; + mutable std::vector _attachmentRanges; + mutable std::vector _chipRanges; +}; +} // namespace facebook::React diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm new file mode 100644 index 00000000000..00fda742284 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -0,0 +1,269 @@ +#include "T3MarkdownTextShadowNode.h" +#include "T3MarkdownTextRunShadowNode.h" +#include +#import + +#include +#include + +namespace facebook::react { + +static constexpr Float ParagraphStyleEncodingOffset = 1000; +static constexpr auto ChipNativeIdPrefix = "t3-chip-"; +static constexpr auto FileChipNativeIdPrefix = "t3-chip-file:"; +static constexpr auto SkillChipNativeIdPrefix = "t3-chip-skill:"; + +static void applyParagraphStyles( + NSMutableAttributedString *attributedString, + const std::vector &styleRanges) +{ + for (const auto &styleRange : styleRanges) { + if (styleRange.length == 0 || styleRange.location >= attributedString.length) { + continue; + } + + const NSRange markerRange = NSMakeRange( + styleRange.location, + MIN(styleRange.length, attributedString.length - styleRange.location)); + const NSRange paragraphRange = [attributedString.string paragraphRangeForRange:markerRange]; + const NSParagraphStyle *existingStyle = + [attributedString attribute:NSParagraphStyleAttributeName + atIndex:paragraphRange.location + effectiveRange:nil]; + NSMutableParagraphStyle *paragraphStyle = + existingStyle ? [existingStyle mutableCopy] : [NSMutableParagraphStyle new]; + paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; + paragraphStyle.headIndent = styleRange.headIndent; + paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + paragraphStyle.tabStops = @[ + [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + location:styleRange.headIndent + options:@{}] + ]; + paragraphStyle.defaultTabInterval = styleRange.headIndent; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:paragraphRange]; + } +} + +static void applyAttachments( + NSMutableAttributedString *attributedString, + const std::vector &attachmentRanges) +{ + for (const auto &attachmentRange : attachmentRanges) { + if (attachmentRange.length == 0 || attachmentRange.location >= attributedString.length) { + continue; + } + + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + attachment.image = [[UIImage alloc] init]; + attachment.bounds = CGRectMake(0, -0.5, 10, 10); + const NSRange range = NSMakeRange( + attachmentRange.location, + MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); + NSAttributedString *attachmentString = + [NSAttributedString attributedStringWithAttachment:attachment]; + [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; + } +} + +T3MarkdownTextShadowNode::T3MarkdownTextShadowNode( + const ShadowNode& sourceShadowNode, + const ShadowNodeFragment& fragment +) : ConcreteViewShadowNode(sourceShadowNode, fragment) { +}; + +Size T3MarkdownTextShadowNode::measureContent( + const LayoutContext& layoutContext, + const LayoutConstraints& layoutConstraints) const { + const auto &baseProps = getConcreteProps(); + + auto baseTextAttributes = TextAttributes::defaultTextAttributes(); + baseTextAttributes.backgroundColor = baseProps.backgroundColor; + baseTextAttributes.allowFontScaling = baseProps.allowFontScaling; + + Float fontSizeMultiplier = 1.0; + if (baseTextAttributes.allowFontScaling) { + fontSizeMultiplier = layoutContext.fontSizeMultiplier; + } + + auto baseAttributedString = AttributedString{}; + auto paragraphStyleRanges = std::vector{}; + auto attachmentRanges = std::vector{}; + auto chipRanges = std::vector{}; + size_t utf16Offset = 0; + const auto &children = getChildren(); + for (size_t i = 0; i < children.size(); i++) { + const auto child = children[i].get(); + if (auto textViewChild = dynamic_cast(child)) { + auto &props = textViewChild->getConcreteProps(); + auto fragment = AttributedString::Fragment{}; + auto textAttributes = TextAttributes::defaultTextAttributes(); + + textAttributes.allowFontScaling = baseProps.allowFontScaling; + textAttributes.backgroundColor = props.backgroundColor; + textAttributes.fontSize = props.fontSize * fontSizeMultiplier; + textAttributes.lineHeight = props.lineHeight * fontSizeMultiplier; + textAttributes.foregroundColor = props.color; + const bool hasParagraphStyle = props.shadowRadius >= ParagraphStyleEncodingOffset; + if (!hasParagraphStyle) { + textAttributes.textShadowColor = props.shadowColor; + textAttributes.textShadowOffset = props.shadowOffset; + textAttributes.textShadowRadius = props.shadowRadius; + } + textAttributes.letterSpacing = props.letterSpacing; + textAttributes.textDecorationColor = props.textDecorationColor; + textAttributes.fontFamily = props.fontFamily; + + if (props.fontStyle == T3MarkdownTextRunFontStyle::Italic) { + textAttributes.fontStyle = FontStyle::Italic; + } else { + textAttributes.fontStyle = FontStyle::Normal; + } + + if (props.fontWeight == T3MarkdownTextRunFontWeight::Bold) { + textAttributes.fontWeight = FontWeight::Bold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::UltraLight) { + textAttributes.fontWeight = FontWeight::UltraLight; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Light) { + textAttributes.fontWeight = FontWeight::Light; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Medium) { + textAttributes.fontWeight = FontWeight::Medium; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Semibold) { + textAttributes.fontWeight = FontWeight::Semibold; + } else if (props.fontWeight == T3MarkdownTextRunFontWeight::Heavy) { + textAttributes.fontWeight = FontWeight::Heavy; + } else { + textAttributes.fontWeight = FontWeight::Regular; + } + + if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::LineThrough) { + textAttributes.textDecorationLineType = TextDecorationLineType::Strikethrough; + } else if (props.textDecorationLine == T3MarkdownTextRunTextDecorationLine::Underline) { + textAttributes.textDecorationLineType = TextDecorationLineType::Underline; + } else { + textAttributes.textDecorationLineType = TextDecorationLineType::None; + } + + if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Solid) { + textAttributes.textDecorationStyle = TextDecorationStyle::Solid; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dotted) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dotted; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Dashed) { + textAttributes.textDecorationStyle = TextDecorationStyle::Dashed; + } else if (props.textDecorationStyle == T3MarkdownTextRunTextDecorationStyle::Double) { + textAttributes.textDecorationStyle = TextDecorationStyle::Double; + } + + if (props.textAlign == T3MarkdownTextRunTextAlign::Left) { + textAttributes.alignment = TextAlignment::Left; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Right) { + textAttributes.alignment = TextAlignment::Right; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Center) { + textAttributes.alignment = TextAlignment::Center; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Justify) { + textAttributes.alignment = TextAlignment::Justified; + } else if (props.textAlign == T3MarkdownTextRunTextAlign::Auto) { + textAttributes.alignment = TextAlignment::Natural; + } + + textAttributes.backgroundColor = props.backgroundColor; + + fragment.string = props.text; + fragment.textAttributes = textAttributes; + + NSString *fragmentText = [NSString stringWithUTF8String:props.text.c_str()]; + const size_t fragmentLength = fragmentText.length; + if (hasParagraphStyle) { + paragraphStyleRanges.push_back(T3MarkdownTextParagraphStyleRange{ + utf16Offset, + fragmentLength, + props.shadowOffset.width, + props.shadowOffset.height, + props.shadowRadius - ParagraphStyleEncodingOffset, + }); + } + if (props.nativeId.rfind(ChipNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + chipRanges.push_back(T3MarkdownTextChipRange{ + utf16Offset, + fragmentLength, + props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0, + }); + } + if (props.nativeId.rfind(FileChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset + 1, + 1, + props.nativeId.substr(std::char_traits::length(FileChipNativeIdPrefix)), + }); + } else if ( + props.nativeId.rfind(SkillChipNativeIdPrefix, 0) == 0 && fragmentLength > 1) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset + 1, + 1, + props.nativeId.substr(std::char_traits::length(SkillChipNativeIdPrefix)), + }); + } + utf16Offset += fragmentLength; + baseAttributedString.appendFragment(std::move(fragment)); + } + } + + _attributedString = baseAttributedString; + _paragraphStyleRanges = paragraphStyleRanges; + _attachmentRanges = attachmentRanges; + _chipRanges = chipRanges; + + NSMutableAttributedString *convertedAttributedString = + [RCTNSAttributedStringFromAttributedString(baseAttributedString) mutableCopy]; + applyParagraphStyles(convertedAttributedString, paragraphStyleRanges); + applyAttachments(convertedAttributedString, attachmentRanges); + + const CGFloat maximumWidth = std::isfinite(layoutConstraints.maximumSize.width) + ? layoutConstraints.maximumSize.width + : CGFLOAT_MAX; + NSTextStorage *textStorage = + [[NSTextStorage alloc] initWithAttributedString:convertedAttributedString]; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.usesFontLeading = NO; + NSTextContainer *textContainer = + [[NSTextContainer alloc] initWithSize:CGSizeMake(maximumWidth, CGFLOAT_MAX)]; + textContainer.lineFragmentPadding = 0; + textContainer.maximumNumberOfLines = baseProps.numberOfLines; + if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Head) { + textContainer.lineBreakMode = NSLineBreakByTruncatingHead; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Middle) { + textContainer.lineBreakMode = NSLineBreakByTruncatingMiddle; + } else if (baseProps.ellipsizeMode == T3MarkdownTextEllipsizeMode::Tail) { + textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + } else { + textContainer.lineBreakMode = NSLineBreakByClipping; + } + [layoutManager addTextContainer:textContainer]; + [textStorage addLayoutManager:layoutManager]; + [layoutManager ensureLayoutForTextContainer:textContainer]; + const CGRect usedRect = [layoutManager usedRectForTextContainer:textContainer]; + + return { + std::clamp( + static_cast(std::ceil(usedRect.size.width)), + layoutConstraints.minimumSize.width, + layoutConstraints.maximumSize.width), + std::clamp( + static_cast(std::ceil(usedRect.size.height)), + layoutConstraints.minimumSize.height, + layoutConstraints.maximumSize.height), + }; +} + +void T3MarkdownTextShadowNode::layout(LayoutContext layoutContext) { + ensureUnsealed(); + setStateData(T3MarkdownTextStateReal{ + _attributedString, + _paragraphStyleRanges, + _attachmentRanges, + _chipRanges, + }); +} +} diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json new file mode 100644 index 00000000000..d51b6c5d9ff --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -0,0 +1,51 @@ +{ + "name": "@t3tools/mobile-markdown-text", + "version": "0.0.0", + "private": true, + "source": "./index.ts", + "files": [ + "assets", + "ios", + "src", + "index.ts", + "LICENSE", + "UPSTREAM.md", + "T3MarkdownText.podspec", + "react-native.config.js" + ], + "main": "./index.ts", + "types": "./index.ts", + "react-native": "./index.ts", + "exports": { + ".": "./index.ts", + "./file-icons": "./src/markdownFileIcons.ts", + "./links": "./src/markdownLinks.ts", + "./markdown": "./src/nativeMarkdownText.ts", + "./primitive": "./src/MarkdownTextPrimitive.tsx", + "./renderer": "./src/SelectableMarkdownText.ios.tsx", + "./types": "./src/SelectableMarkdownText.types.ts" + }, + "peerDependencies": { + "expo-asset": "*", + "expo-clipboard": "*", + "expo-haptics": "*", + "expo-symbols": "*", + "react": "*", + "react-native": "*", + "react-native-nitro-markdown": "*" + }, + "codegenConfig": { + "name": "T3MarkdownTextSpec", + "type": "all", + "jsSrcsDir": "src", + "ios": { + "componentProvider": { + "T3MarkdownText": "T3MarkdownText", + "T3MarkdownTextRun": "T3MarkdownTextRun" + } + }, + "outputDir": { + "ios": "ios/generated" + } + } +} diff --git a/apps/mobile/modules/t3-markdown-text/react-native.config.js b/apps/mobile/modules/t3-markdown-text/react-native.config.js new file mode 100644 index 00000000000..6b10ea26eec --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + ios: { + podspecPath: "T3MarkdownText.podspec", + }, + android: null, + }, + }, +}; diff --git a/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx new file mode 100644 index 00000000000..ffbc0e2fcb6 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/CopyTextButton.tsx @@ -0,0 +1,73 @@ +import { SymbolView } from "expo-symbols"; +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; +import { memo, useEffect, useRef, useState } from "react"; +import { Pressable, type ColorValue } from "react-native"; + +const COPY_FEEDBACK_DURATION_MS = 1200; + +function copyTextWithHaptic(value: string): void { + void Clipboard.setStringAsync(value); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +} + +export const CopyTextButton = memo(function CopyTextButton(props: { + readonly accessibilityLabel: string; + readonly text: string; + readonly tintColor: ColorValue; + readonly copiedTintColor?: ColorValue; + readonly backgroundColor?: ColorValue; + readonly borderColor?: ColorValue; + readonly iconSize?: number; + readonly buttonSize?: number; +}) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx new file mode 100644 index 00000000000..6ed7fecd2d3 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { Platform, StyleSheet, Text as RNText, type TextProps, type ViewStyle } from "react-native"; +import T3MarkdownTextRunNativeComponent from "./T3MarkdownTextRunNativeComponent"; +import T3MarkdownTextNativeComponent from "./T3MarkdownTextNativeComponent"; +import { flattenStyles } from "./util"; + +const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ + false, + StyleSheet.create({}), +]); + +const textDefaults: TextProps = { + allowFontScaling: true, + selectable: true, +}; + +const useTextAncestorContext = () => React.useContext(TextAncestorContext); + +/** + * Event fired by `onSelectionChange`. `start`/`end` are 0-based UTF-16 indices + * into the rendered string. `start === end` means the selection was cleared. + */ +export type SelectionChangeEvent = { + nativeEvent: { target: number; start: number; end: number }; +}; + +export type MarkdownTextPrimitiveProps = TextProps & { + uiTextView?: boolean; + /** + * Fired when the native text selection changes. Only fires on iOS when + * `uiTextView` is true. Note: fires on every selection-edge adjustment + * (e.g. dragging a selection handle), so consumers driving expensive work + * off this event should debounce. + */ + onSelectionChange?: (event: SelectionChangeEvent) => void; +}; + +function MarkdownTextPrimitiveChild({ style, children, ...rest }: MarkdownTextPrimitiveProps) { + const [isAncestor, rootStyle] = useTextAncestorContext(); + + // Flatten the styles, and apply the root styles when needed + const flattenedStyle = React.useMemo(() => flattenStyles(rootStyle, style), [rootStyle, style]); + const contextValue = React.useMemo<[boolean, ViewStyle]>( + () => [true, flattenedStyle], + [flattenedStyle], + ); + let childPosition = 0; + const nativeChildren = React.Children.toArray(children).map((child) => { + const position = childPosition; + childPosition += 1; + + if (React.isValidElement(child)) { + return child; + } + if (typeof child !== "string" && typeof child !== "number") { + return null; + } + + const text = child.toString(); + return ( + // @ts-expect-error The generated run props do not include inherited Text props. + + ); + }); + + if (!isAncestor) { + return ( + + + {nativeChildren} + + + ); + } + + return <>{nativeChildren}; +} + +function MarkdownTextPrimitiveInner(props: MarkdownTextPrimitiveProps) { + const [isAncestor] = useTextAncestorContext(); + + // Even if the uiTextView prop is set, we can still default to using + // normal selection (i.e. base RN text) if the text doesn't need to be + // selectable + if ((!props.selectable || !props.uiTextView) && !isAncestor) { + return ; + } + return ; +} + +export function MarkdownTextPrimitive(props: MarkdownTextPrimitiveProps) { + if (Platform.OS !== "ios") { + return ; + } + return ; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx new file mode 100644 index 00000000000..757b6c66011 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -0,0 +1,647 @@ +import { useEffect, useState } from "react"; +import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { CopyTextButton } from "./CopyTextButton"; +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, +} from "./nativeMarkdownText"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, +} from "./SelectableMarkdownText.types"; + +type HighlightedCode = ReadonlyArray>; + +const highlightedCodeCache = new Map(); +const highlightedCodePromiseCache = new Map>(); +const HIGHLIGHTED_CODE_CACHE_LIMIT = 64; + +function nodeKey(node: MarkdownNode, index: number): string { + return `${node.type}:${node.beg ?? index}:${node.end ?? index}`; +} + +function nodeText(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeText).join(""); +} + +function documentFor(node: MarkdownNode): MarkdownNode { + return node.type === "document" ? node : { type: "document", children: [node] }; +} + +function SelectableNode(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + return ( + + ); +} + +function codeHighlightCacheKey( + code: string, + language: string | undefined, + theme: "light" | "dark", +): string { + return `${theme}:${language ?? "text"}:${code}`; +} + +function cacheHighlightedCode(key: string, tokens: HighlightedCode): void { + highlightedCodeCache.delete(key); + highlightedCodeCache.set(key, tokens); + + while (highlightedCodeCache.size > HIGHLIGHTED_CODE_CACHE_LIMIT) { + const oldestKey = highlightedCodeCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + highlightedCodeCache.delete(oldestKey); + } +} + +function loadHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): Promise { + const key = codeHighlightCacheKey(code, language, theme); + const cached = highlightedCodeCache.get(key); + if (cached) { + return Promise.resolve(cached); + } + + const pending = highlightedCodePromiseCache.get(key); + if (pending) { + return pending; + } + + const promise = highlightCode({ code, language, theme }) + .then((tokens) => { + cacheHighlightedCode(key, tokens); + highlightedCodePromiseCache.delete(key); + return tokens; + }) + .catch((error) => { + highlightedCodePromiseCache.delete(key); + throw error; + }); + highlightedCodePromiseCache.set(key, promise); + return promise; +} + +function useHighlightedCode( + code: string, + language: string | undefined, + theme: "light" | "dark", + highlightCode: MarkdownCodeHighlighter, +): HighlightedCode | null { + const key = codeHighlightCacheKey(code, language, theme); + const [highlighted, setHighlighted] = useState<{ + readonly key: string; + readonly tokens: HighlightedCode | null; + }>(() => ({ + key, + tokens: highlightedCodeCache.get(key) ?? null, + })); + + useEffect(() => { + let active = true; + const cached = highlightedCodeCache.get(key); + if (cached) { + cacheHighlightedCode(key, cached); + setHighlighted({ key, tokens: cached }); + return () => { + active = false; + }; + } + + void loadHighlightedCode(code, language, theme, highlightCode) + .then((tokens) => { + if (active) { + setHighlighted({ key, tokens }); + } + }) + .catch(() => { + if (active) { + setHighlighted({ key, tokens: null }); + } + }); + return () => { + active = false; + }; + }, [code, highlightCode, key, language, theme]); + + return highlighted.key === key ? highlighted.tokens : null; +} + +function HighlightedCodeText(props: { + readonly content: string; + readonly highlighted: HighlightedCode | null; + readonly textStyle: NativeMarkdownTextStyle; +}) { + if (!props.highlighted) { + return ( + + {props.content} + + ); + } + const highlighted = props.highlighted; + let sourceOffset = 0; + const keyOccurrences = new Map(); + const keyedLines = highlighted.map((line) => { + const lineStart = sourceOffset; + const tokens = line.map((token) => { + const start = sourceOffset; + sourceOffset += token.content.length; + const signature = `${start}:${token.content}:${token.color ?? ""}:${token.fontStyle ?? ""}`; + const occurrence = keyOccurrences.get(signature) ?? 0; + keyOccurrences.set(signature, occurrence + 1); + return { key: `${signature}:${occurrence}`, token }; + }); + sourceOffset += 1; + return { + key: `line:${lineStart}:${line.map((token) => token.content).join("")}`, + tokens, + }; + }); + + return ( + + {keyedLines.map((line, lineIndex) => ( + + {line.tokens.map(({ key, token }) => ( + + {token.content} + + ))} + {lineIndex + 1 < keyedLines.length ? "\n" : ""} + + ))} + + ); +} + +function NativeCodeBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly compact?: boolean; +}) { + const content = nodeText(props.node).replace(/\n$/, ""); + const colorScheme = useColorScheme(); + const theme = colorScheme === "dark" ? "dark" : "light"; + const highlighted = useHighlightedCode(content, props.node.language, theme, props.highlightCode); + const languageLabel = props.node.language?.toUpperCase() ?? "CODE"; + return ( + + + + {languageLabel} + + + + + + + + ); +} + +function collectTableRows(node: MarkdownNode): MarkdownNode[] { + const rows: MarkdownNode[] = []; + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + rows.push(child); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return rows; +} + +function NativeTable(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const rows = collectTableRows(props.node); + return ( + + + {rows.map((row, rowIndex) => ( + + {(row.children ?? []).map((cell, cellIndex) => ( + + + rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, + )} + textStyle={props.textStyle} + /> + + ))} + + ))} + + + ); +} + +function NativeMarkdownImage(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const href = props.node.href; + if (!href) { + return ; + } + + return ( + + + {props.node.alt ? ( + + {props.node.alt} + + ) : null} + + ); +} + +function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { + const groups: MarkdownNode[] = []; + let inline: MarkdownNode[] = []; + const flush = () => { + if (inline.length === 0) { + return; + } + groups.push({ type: "paragraph", children: inline }); + inline = []; + }; + + for (const node of nodes) { + if (node.type === "image") { + flush(); + groups.push(node); + } else { + inline.push(node); + } + } + flush(); + return groups; +} + +function NativeMixedParagraph(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; +}) { + return ( + + {inlineGroups(props.node.children ?? []).map((child, index) => + child.type === "image" ? ( + + ) : ( + + ), + )} + + ); +} + +function NativeList(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly depth: number; +}) { + const ordered = props.node.ordered ?? false; + const start = props.node.start ?? 1; + const nested = props.depth > 0; + return ( + + {(props.node.children ?? []).map((item, index) => { + const taskMarker = item.type === "task_list_item"; + const marker = taskMarker + ? item.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : props.depth % 3 === 1 + ? "◦" + : props.depth % 3 === 2 + ? "▪︎" + : "•"; + const markerWidth = ordered ? 28 : taskMarker ? 20 : 18; + const markerOffset = taskMarker ? 3 : ordered ? 0 : 2; + return ( + + + + {marker} + + + + {nativeMarkdownListItemBlocks(item).map((child, childIndex) => ( + + ))} + + + ); + })} + + ); +} + +export function NativeMarkdownBlock(props: { + readonly node: MarkdownNode; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly depth?: number; + readonly compact?: boolean; +}) { + const depth = props.depth ?? 0; + switch (props.node.type) { + case "document": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "code_block": + return ( + + ); + case "table": + return ; + case "image": + return ; + case "horizontal_rule": + return ( + + ); + case "blockquote": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + case "list": + return ( + + ); + case "paragraph": + return (props.node.children ?? []).some((child) => child.type === "image") ? ( + + ) : ( + + ); + case "html_block": + case "math_block": + return ( + + + + ); + case "table_head": + case "table_body": + case "table_row": + case "table_cell": + case "list_item": + case "task_list_item": + return ( + + {(props.node.children ?? []).map((child, index) => ( + + ))} + + ); + default: + return ; + } +} diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx new file mode 100644 index 00000000000..c6495eed860 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -0,0 +1,257 @@ +import { useEffect, useMemo, useState } from "react"; +import { Asset } from "expo-asset"; +import { Image, Linking, type TextStyle, useColorScheme } from "react-native"; + +import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; +import { markdownFileIconSource } from "./markdownFileIcons"; +import type { MarkdownFileIcon } from "./markdownLinks"; +import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; +import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types"; + +const EXTERNAL_LINK_PREFIX = "◉ "; +const FILE_LINK_PREFIX = "\u00A0\uFFFC\u00A0"; +const CHIP_SUFFIX = "\u00A0"; +const SKILL_ICON_PLACEHOLDER = "\uFFFC"; +const PARAGRAPH_STYLE_ENCODING_OFFSET = 1000; + +function useFileIconUris(runs: ReadonlyArray) { + const iconSignature = JSON.stringify( + [...new Set(runs.flatMap((run) => (run.fileIcon ? [run.fileIcon] : [])))].sort(), + ); + const icons = useMemo( + () => JSON.parse(iconSignature) as ReadonlyArray, + [iconSignature], + ); + const [uris, setUris] = useState>(() => new Map()); + + useEffect(() => { + let cancelled = false; + + void Promise.all( + icons.map(async (icon) => { + const source = markdownFileIconSource(icon); + const fallbackUri = Image.resolveAssetSource(source).uri; + if (typeof source !== "number" && typeof source !== "string") { + return [icon, fallbackUri] as const; + } + try { + const asset = Asset.fromModule(source); + await asset.downloadAsync(); + return [icon, asset.localUri ?? fallbackUri] as const; + } catch { + return [icon, fallbackUri] as const; + } + }), + ).then((entries) => { + if (!cancelled) { + setUris(new Map(entries)); + } + }); + + return () => { + cancelled = true; + }; + }, [icons]); + + return uris; +} + +function runKeySignature(run: NativeMarkdownTextRun): string { + return [ + run.text, + run.bold, + run.italic, + run.strikethrough, + run.code, + run.href, + run.externalHost, + run.fileIcon, + run.skillName, + run.skillLabel, + run.role, + run.headingLevel, + run.depth, + run.spacing, + run.firstLineHeadIndent, + run.headIndent, + run.paragraphSpacing, + ].join(":"); +} + +function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { + const isFile = run.fileIcon != null; + const isSkill = run.skillName != null; + const isChip = isFile || isSkill; + const headingLevel = Math.max(1, Math.min(6, run.headingLevel ?? 1)); + const headingFontSize = [22, 19, 17, 16, 15, 15][headingLevel - 1] ?? 15; + const isHeading = run.role === "heading"; + const isCodeBlock = run.role === "code-block" || run.role === "code-language"; + const hasParagraphStyle = run.headIndent !== undefined; + const textDecorationLine = run.strikethrough ? "line-through" : run.href ? "underline" : "none"; + + return { + color: isFile + ? textStyle.fileTextColor + : isSkill + ? textStyle.skillTextColor + : run.href + ? textStyle.linkColor + : isHeading + ? textStyle.strongColor + : run.role === "quote-marker" + ? textStyle.quoteMarkerColor + : run.role === "divider" + ? textStyle.dividerColor + : run.role === "code-language" + ? textStyle.mutedColor + : run.role === "list-marker" + ? textStyle.mutedColor + : run.code || isFile + ? textStyle.codeColor + : run.bold + ? textStyle.strongColor + : textStyle.color, + fontFamily: isChip + ? "DMSans_500Medium" + : run.code || isCodeBlock + ? "ui-monospace" + : isHeading + ? textStyle.headingFontFamily + : run.bold + ? textStyle.boldFontFamily + : textStyle.fontFamily, + fontSize: + run.role === "spacer" + ? (run.spacing ?? 10) + : run.role === "list-break" + ? textStyle.fontSize + : isHeading + ? headingFontSize + : run.role === "code-language" + ? 11 + : run.code || isChip || isCodeBlock + ? Math.max(12, textStyle.fontSize - 2) + : textStyle.fontSize, + lineHeight: + run.role === "spacer" + ? (run.spacing ?? 10) + : run.role === "list-break" + ? textStyle.lineHeight + (run.spacing ?? 0) + : isHeading + ? Math.max(headingFontSize + 6, 20) + : isCodeBlock + ? 18 + : textStyle.lineHeight, + fontStyle: run.italic ? "italic" : "normal", + fontWeight: isHeading || run.bold ? "700" : isChip ? "500" : "400", + textDecorationLine, + backgroundColor: isCodeBlock + ? textStyle.codeBlockBackgroundColor + : isSkill + ? textStyle.skillBackgroundColor + : run.code + ? textStyle.codeBackgroundColor + : isFile + ? textStyle.fileBackgroundColor + : undefined, + ...(hasParagraphStyle + ? { + shadowColor: "transparent", + shadowOffset: { + width: run.firstLineHeadIndent ?? 0, + height: run.headIndent, + }, + shadowRadius: PARAGRAPH_STYLE_ENCODING_OFFSET + (run.paragraphSpacing ?? 0), + } + : {}), + }; +} + +export function NativeMarkdownSelectableText(props: { + readonly runs: ReadonlyArray; + readonly textStyle: NativeMarkdownTextStyle; +}) { + const colorScheme = useColorScheme(); + const fileIconUris = useFileIconUris(props.runs); + const occurrences = new Map(); + const prefixedExternalLinks = new Set(); + const keyedRuns = props.runs.map((run) => { + const signature = runKeySignature(run); + const occurrence = occurrences.get(signature) ?? 0; + occurrences.set(signature, occurrence + 1); + + let text = run.text; + if (run.fileIcon) { + text = `${FILE_LINK_PREFIX}${text}${CHIP_SUFFIX}`; + } else if (run.skillName && run.skillLabel) { + text = `\u00A0${SKILL_ICON_PLACEHOLDER}\u00A0${run.skillLabel}${CHIP_SUFFIX}`; + } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { + prefixedExternalLinks.add(run.href); + text = `${EXTERNAL_LINK_PREFIX}${text}`; + } + + return { key: `${signature}:${occurrence}`, run, text }; + }); + // T3MarkdownText only rebuilds its attributed string during native layout. A + // color-only child update can otherwise leave the previous appearance cached. + const appearanceKey = [ + colorScheme ?? "unspecified", + props.textStyle.color, + props.textStyle.strongColor, + props.textStyle.mutedColor, + props.textStyle.linkColor, + props.textStyle.codeColor, + props.textStyle.codeBackgroundColor, + props.textStyle.codeBlockBackgroundColor, + props.textStyle.fileBackgroundColor, + props.textStyle.fileTextColor, + props.textStyle.skillBackgroundColor, + props.textStyle.skillTextColor, + props.textStyle.quoteMarkerColor, + props.textStyle.dividerColor, + ].join(":"); + + return ( + + {keyedRuns.map(({ key, run, text }) => { + const href = run.href; + return ( + { + void Linking.openURL(href); + } + : undefined + } + > + {text} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx new file mode 100644 index 00000000000..7c8f8d1bd55 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -0,0 +1,80 @@ +import { useMemo } from "react"; +import { View } from "react-native"; +import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, +} from "./nativeMarkdownText"; +import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; +import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; +import type { + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +const EMPTY_SKILLS: ReadonlyArray = []; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText({ + markdown, + skills = EMPTY_SKILLS, + textStyle, + highlightCode, + marginTop = 0, + marginBottom = 0, +}: SelectableMarkdownTextProps) { + const chunks = useMemo(() => { + const document = parseMarkdownWithOptions(markdown, { + gfm: true, + html: true, + math: false, + }); + return nativeMarkdownDocumentChunks(document).map((chunk) => + chunk.kind === "selectable" + ? { + ...chunk, + runs: nativeMarkdownDocumentRuns(chunk.node, skills), + } + : chunk, + ); + }, [markdown, skills]); + + return ( + + {chunks.map((chunk, index) => { + const content = + chunk.kind === "rich" ? ( + + ) : ( + + ); + + return ( + + {content} + + ); + })} + + ); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx new file mode 100644 index 00000000000..fcb2472f648 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx @@ -0,0 +1,13 @@ +import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types"; + +export type { + MarkdownCodeHighlighter, + MarkdownHighlightedToken, + NativeMarkdownTextStyle, + SelectableMarkdownSkill, + SelectableMarkdownTextProps, +} from "./SelectableMarkdownText.types"; + +export function SelectableMarkdownText(_props: SelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts new file mode 100644 index 00000000000..bd67d9110e5 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts @@ -0,0 +1,46 @@ +export interface NativeMarkdownTextStyle { + readonly color: string; + readonly strongColor: string; + readonly mutedColor: string; + readonly linkColor: string; + readonly codeColor: string; + readonly codeBackgroundColor: string; + readonly codeBlockBackgroundColor: string; + readonly fileBackgroundColor: string; + readonly fileTextColor: string; + readonly skillBackgroundColor: string; + readonly skillTextColor: string; + readonly quoteMarkerColor: string; + readonly dividerColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly fontFamily: string; + readonly headingFontFamily: string; + readonly boldFontFamily: string; +} + +export interface MarkdownHighlightedToken { + readonly content: string; + readonly color: string | null; + readonly fontStyle: number | null; +} + +export type MarkdownCodeHighlighter = (input: { + readonly code: string; + readonly language?: string | null; + readonly theme: "light" | "dark"; +}) => Promise>>; + +export interface SelectableMarkdownSkill { + readonly name: string; + readonly displayName?: string | null; +} + +export interface SelectableMarkdownTextProps { + readonly markdown: string; + readonly textStyle: NativeMarkdownTextStyle; + readonly highlightCode: MarkdownCodeHighlighter; + readonly skills?: ReadonlyArray; + readonly marginTop?: number; + readonly marginBottom?: number; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts new file mode 100644 index 00000000000..656ad47d252 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextNativeComponent.ts @@ -0,0 +1,55 @@ +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; +import type { ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; + +interface TargetedEvent { + target: Int32; +} + +interface TextLayoutEvent extends TargetedEvent { + lines: string[]; +} + +/** + * Event fired when text selection changes in the MarkdownTextPrimitive. + * @property target - The view tag identifier + * @property start - The start index of the selected range (0-based) + * @property end - The end index of the selected range (0-based, exclusive) + */ +interface SelectionChangeEvent extends TargetedEvent { + start: Int32; + end: Int32; +} + +type EllipsizeMode = "head" | "middle" | "tail" | "clip"; + +interface NativeProps extends ViewProps { + numberOfLines?: Int32; + allowFontScaling?: WithDefault; + ellipsizeMode?: WithDefault; + selectable?: boolean; + onTextLayout?: BubblingEventHandler; + /** + * Callback fired when the text selection changes. + * + * @example + * ```tsx + * { + * console.log('Selection:', event.nativeEvent.start, event.nativeEvent.end); + * }} + * > + * Selectable text + * + * ``` + */ + onSelectionChange?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownText", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts new file mode 100644 index 00000000000..7f8fab8d844 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts @@ -0,0 +1,51 @@ +import type { ColorValue, ViewProps } from "react-native"; +import type { + BubblingEventHandler, + Float, + Int32, + WithDefault, +} from "react-native/Libraries/Types/CodegenTypes"; +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent"; + +interface TargetedEvent { + target: Int32; +} + +type TextDecorationLine = "none" | "underline" | "line-through"; + +type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed"; + +export type NativeFontWeight = + | "normal" + | "bold" + | "ultraLight" + | "light" + | "medium" + | "semibold" + | "heavy"; + +type FontStyle = "normal" | "italic"; + +type TextAlign = "auto" | "left" | "right" | "center" | "justify"; + +interface NativeProps extends ViewProps { + text: string; + color?: ColorValue; + fontSize?: Float; + fontStyle?: WithDefault; + fontWeight?: WithDefault; + fontFamily?: string; + letterSpacing?: Float; + lineHeight?: Float; + textDecorationLine?: WithDefault; + textDecorationStyle?: WithDefault; + textDecorationColor?: ColorValue; + textAlign?: WithDefault; + shadowRadius?: WithDefault; + onPress?: BubblingEventHandler; + onLongPress?: BubblingEventHandler; +} + +export default codegenNativeComponent("T3MarkdownTextRun", { + excludedPlatforms: ["android"], +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts new file mode 100644 index 00000000000..84ecc39debf --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownFileIcons.ts @@ -0,0 +1,34 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownFileIcon } from "./markdownLinks"; + +const MARKDOWN_FILE_ICON_SOURCES: Readonly> = { + agents: require("../assets/file-icons/file_type_agents.png"), + c: require("../assets/file-icons/file_type_c.png"), + cpp: require("../assets/file-icons/file_type_cpp.png"), + css: require("../assets/file-icons/file_type_css.png"), + default: require("../assets/file-icons/default_file.png"), + go: require("../assets/file-icons/file_type_go.png"), + html: require("../assets/file-icons/file_type_html.png"), + java: require("../assets/file-icons/file_type_java.png"), + javascript: require("../assets/file-icons/file_type_js.png"), + json: require("../assets/file-icons/file_type_json.png"), + kotlin: require("../assets/file-icons/file_type_kotlin.png"), + markdown: require("../assets/file-icons/file_type_markdown.png"), + npm: require("../assets/file-icons/file_type_npm.png"), + python: require("../assets/file-icons/file_type_python.png"), + "react-typescript": require("../assets/file-icons/file_type_reactts.png"), + rust: require("../assets/file-icons/file_type_rust.png"), + shell: require("../assets/file-icons/file_type_shell.png"), + sql: require("../assets/file-icons/file_type_sql.png"), + swift: require("../assets/file-icons/file_type_swift.png"), + toml: require("../assets/file-icons/file_type_toml.png"), + tsconfig: require("../assets/file-icons/file_type_tsconfig.png"), + typescript: require("../assets/file-icons/file_type_typescript.png"), + xml: require("../assets/file-icons/file_type_xml.png"), + yaml: require("../assets/file-icons/file_type_yaml.png"), +}; + +export function markdownFileIconSource(icon: MarkdownFileIcon): ImageSourcePropType { + return MARKDOWN_FILE_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts new file mode 100644 index 00000000000..4e513eaf00d --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -0,0 +1,208 @@ +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; +const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/; +const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/; +const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/; +const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; +const POSIX_FILE_ROOT_PREFIXES = [ + "/Users/", + "/home/", + "/tmp/", + "/var/", + "/etc/", + "/opt/", + "/mnt/", + "/Volumes/", + "/private/", + "/root/", +] as const; + +export type MarkdownLinkPresentation = + | { + readonly kind: "external"; + readonly href: string; + readonly host: string; + } + | { + readonly kind: "file"; + readonly icon: MarkdownFileIcon; + readonly label: string; + } + | { + readonly kind: "link"; + readonly href: string | null; + }; + +export type MarkdownFileIcon = + | "agents" + | "c" + | "cpp" + | "css" + | "default" + | "go" + | "html" + | "java" + | "javascript" + | "json" + | "kotlin" + | "markdown" + | "npm" + | "python" + | "react-typescript" + | "rust" + | "shell" + | "sql" + | "swift" + | "toml" + | "tsconfig" + | "typescript" + | "xml" + | "yaml"; + +const FILE_ICON_BY_EXTENSION: Readonly> = { + c: "c", + cc: "cpp", + cpp: "cpp", + cxx: "cpp", + css: "css", + go: "go", + htm: "html", + html: "html", + java: "java", + js: "javascript", + jsx: "javascript", + json: "json", + jsonc: "json", + kt: "kotlin", + kts: "kotlin", + md: "markdown", + mdc: "markdown", + mdx: "markdown", + py: "python", + rs: "rust", + scss: "css", + sh: "shell", + sql: "sql", + swift: "swift", + toml: "toml", + ts: "typescript", + tsx: "react-typescript", + xml: "xml", + yaml: "yaml", + yml: "yaml", + zsh: "shell", +}; + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function normalizeDestination(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; +} + +function fileUrlPath(href: string): string | null { + try { + const parsed = new URL(href); + if (parsed.protocol.toLowerCase() !== "file:") { + return null; + } + const path = /^\/[A-Za-z]:[\\/]/.test(parsed.pathname) + ? parsed.pathname.slice(1) + : parsed.pathname; + const lineMatch = parsed.hash.match(/^#L(\d+)(?:C(\d+))?$/i); + return `${safeDecode(path)}${ + lineMatch?.[1] ? `:${lineMatch[1]}${lineMatch[2] ? `:${lineMatch[2]}` : ""}` : "" + }`; + } catch { + return null; + } +} + +function looksLikePosixFilesystemPath(path: string): boolean { + if (!path.startsWith("/")) { + return false; + } + if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) { + return true; + } + if (POSITION_SUFFIX_PATTERN.test(path)) { + return true; + } + const basename = path.slice(path.lastIndexOf("/") + 1); + return /\.[A-Za-z0-9_-]+$/.test(basename); +} + +function looksLikeFilePath(value: string): boolean { + if (WINDOWS_DRIVE_PATH_PATTERN.test(value) || WINDOWS_UNC_PATH_PATTERN.test(value)) { + return true; + } + if (RELATIVE_PATH_PREFIX_PATTERN.test(value)) { + return true; + } + if (value.startsWith("/")) { + return looksLikePosixFilesystemPath(value); + } + return RELATIVE_FILE_PATH_PATTERN.test(value) || RELATIVE_FILE_NAME_PATTERN.test(value); +} + +function fileLabel(value: string): string { + const normalized = value.replaceAll("\\", "/"); + const basename = normalized.slice(normalized.lastIndexOf("/") + 1); + return basename || normalized; +} + +export function resolveMarkdownFileIcon(value: string): MarkdownFileIcon { + const basename = fileLabel(value).replace(POSITION_SUFFIX_PATTERN, "").toLowerCase(); + if (basename === "agents.md") { + return "agents"; + } + if (basename === "package.json") { + return "npm"; + } + if ( + basename === "tsconfig.json" || + (basename.startsWith("tsconfig.") && basename.endsWith(".json")) + ) { + return "tsconfig"; + } + const extension = basename.includes(".") ? basename.slice(basename.lastIndexOf(".") + 1) : ""; + return FILE_ICON_BY_EXTENSION[extension] ?? "default"; +} + +export function resolveMarkdownLinkPresentation(href: string): MarkdownLinkPresentation { + const normalized = normalizeDestination(href); + try { + const parsed = new URL(normalized); + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return { + kind: "external", + href: parsed.toString(), + host: parsed.hostname, + }; + } + } catch { + // Relative paths and non-URL link destinations are handled below. + } + + const fileTarget = normalized.toLowerCase().startsWith("file:") + ? fileUrlPath(normalized) + : safeDecode(normalized.split(/[?#]/, 1)[0] ?? normalized); + if (fileTarget && looksLikeFilePath(fileTarget)) { + return { + kind: "file", + icon: resolveMarkdownFileIcon(fileTarget), + label: fileLabel(fileTarget), + }; + } + + return { + kind: "link", + href: /^(?:mailto|tel):/i.test(normalized) ? normalized : null, + }; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts new file mode 100644 index 00000000000..6751e165f1c --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -0,0 +1,751 @@ +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; +import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; + +export interface NativeMarkdownTextRun { + readonly text: string; + readonly bold?: boolean; + readonly italic?: boolean; + readonly strikethrough?: boolean; + readonly code?: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly skillName?: string; + readonly skillLabel?: string; + readonly role?: + | "body" + | "heading" + | "list-marker" + | "list-break" + | "quote-marker" + | "code-block" + | "code-language" + | "divider" + | "spacer"; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +export type NativeMarkdownDocumentChunk = + | { + readonly kind: "selectable"; + readonly key: string; + readonly node: MarkdownNode; + } + | { + readonly kind: "rich"; + readonly key: string; + readonly node: MarkdownNode; + }; + +interface RunContext { + readonly bold: boolean; + readonly italic: boolean; + readonly strikethrough: boolean; + readonly code: boolean; + readonly href?: string; + readonly externalHost?: string; + readonly fileIcon?: MarkdownFileIcon; + readonly role?: NativeMarkdownTextRun["role"]; + readonly headingLevel?: number; + readonly depth?: number; + readonly spacing?: number; + readonly firstLineHeadIndent?: number; + readonly headIndent?: number; + readonly paragraphSpacing?: number; +} + +const EMPTY_CONTEXT: RunContext = { + bold: false, + italic: false, + strikethrough: false, + code: false, +}; + +const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; + +function decodeHtmlEntitiesOnce(value: string): string { + return value.replace( + /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, + (entity, decimal: string | undefined, hexadecimal: string | undefined) => { + if (decimal) { + return String.fromCodePoint(Number.parseInt(decimal, 10)); + } + if (hexadecimal) { + return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); + } + switch (entity.toLowerCase()) { + case "&": + return "&"; + case "'": + return "'"; + case ">": + return ">"; + case "<": + return "<"; + case " ": + return "\u00a0"; + case """: + return '"'; + default: + return entity; + } + }, + ); +} + +function decodeHtmlEntities(value: string): string { + let decoded = value; + for (let pass = 0; pass < 2; pass += 1) { + const next = decodeHtmlEntitiesOnce(decoded); + if (next === decoded) { + break; + } + decoded = next; + } + return decoded; +} + +function textNodeContent(value: string): string { + return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); +} + +function inlineHtmlText(value: string): string { + if (/^$/i.test(value.trim())) { + return "\n"; + } + return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); +} + +function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { + return ( + left.bold === right.bold && + left.italic === right.italic && + left.strikethrough === right.strikethrough && + left.code === right.code && + left.href === right.href && + left.externalHost === right.externalHost && + left.fileIcon === right.fileIcon && + left.skillName === right.skillName && + left.skillLabel === right.skillLabel && + left.role === right.role && + left.headingLevel === right.headingLevel && + left.depth === right.depth && + left.spacing === right.spacing && + left.firstLineHeadIndent === right.firstLineHeadIndent && + left.headIndent === right.headIndent && + left.paragraphSpacing === right.paragraphSpacing + ); +} + +function appendRun( + runs: NativeMarkdownTextRun[], + text: string, + context: RunContext, +): NativeMarkdownTextRun[] { + if (text.length === 0) { + return runs; + } + + const run: NativeMarkdownTextRun = { + text, + ...(context.bold ? { bold: true } : {}), + ...(context.italic ? { italic: true } : {}), + ...(context.strikethrough ? { strikethrough: true } : {}), + ...(context.code ? { code: true } : {}), + ...(context.href ? { href: context.href } : {}), + ...(context.externalHost ? { externalHost: context.externalHost } : {}), + ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), + ...(context.role ? { role: context.role } : {}), + ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), + ...(context.depth ? { depth: context.depth } : {}), + ...(context.spacing ? { spacing: context.spacing } : {}), + ...(context.firstLineHeadIndent !== undefined + ? { firstLineHeadIndent: context.firstLineHeadIndent } + : {}), + ...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}), + ...(context.paragraphSpacing !== undefined + ? { paragraphSpacing: context.paragraphSpacing } + : {}), + }; + const previous = runs.at(-1); + if (previous && sameRunStyle(previous, run)) { + runs[runs.length - 1] = { ...previous, text: previous.text + run.text }; + return runs; + } + + runs.push(run); + return runs; +} + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +function formatSkillLabel(skill: SelectableMarkdownSkill): string { + const displayName = skill.displayName?.trim(); + if (displayName) { + return displayName; + } + return skill.name + .split(/[\s:_-]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function decorateSkillRuns( + runs: ReadonlyArray, + skills: ReadonlyArray, +): ReadonlyArray { + if (skills.length === 0) { + return runs; + } + const skillByName = new Map(skills.map((skill) => [skill.name, skill])); + const decorated: NativeMarkdownTextRun[] = []; + + for (const run of runs) { + if (run.code || run.href || run.fileIcon || run.role === "code-block") { + decorated.push(run); + continue; + } + + let cursor = 0; + let matched = false; + for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + const skill = skillByName.get(name); + if (!skill) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + name.length + 1; + if (start > cursor) { + decorated.push({ ...run, text: run.text.slice(cursor, start) }); + } + decorated.push({ + ...run, + text: run.text.slice(start, end), + skillName: name, + skillLabel: formatSkillLabel(skill), + }); + cursor = end; + matched = true; + } + if (!matched) { + decorated.push(run); + } else if (cursor < run.text.length) { + decorated.push({ ...run, text: run.text.slice(cursor) }); + } + } + + return decorated; +} + +function appendChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function nodeTextContent(node: MarkdownNode): string { + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(nodeTextContent).join(""); +} + +function appendNode( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "text": + case "math_inline": + return appendRun(runs, textNodeContent(nodeTextContent(node)), context); + case "html_inline": + return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + case "code_inline": + return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "soft_break": + return appendRun(runs, " ", context); + case "line_break": + return appendRun(runs, "\n", context); + case "bold": + return appendChildren(runs, node, { ...context, bold: true }); + case "italic": + return appendChildren(runs, node, { ...context, italic: true }); + case "strikethrough": + return appendChildren(runs, node, { ...context, strikethrough: true }); + case "link": { + const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); + if (presentation.kind === "file") { + return appendRun(runs, presentation.label, { + ...context, + fileIcon: presentation.icon, + }); + } + if (presentation.kind === "external") { + return appendChildren(runs, node, { + ...context, + href: presentation.href, + externalHost: presentation.host, + }); + } + return appendChildren(runs, node, { + ...context, + ...(presentation.href ? { href: presentation.href } : {}), + }); + } + case "image": + return appendRun(runs, node.alt ?? node.title ?? "", context); + default: + return appendChildren(runs, node, context); + } +} + +export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray { + return appendChildren([], node, EMPTY_CONTEXT); +} + +function appendBlockTerminator( + runs: NativeMarkdownTextRun[], + context: RunContext, +): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", context); +} + +function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] { + return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing }); +} + +function appendInlineChildren( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + context: RunContext, +): NativeMarkdownTextRun[] { + for (const child of node.children ?? []) { + appendNode(runs, child, context); + } + return runs; +} + +function isInlineNode(node: MarkdownNode): boolean { + return ( + node.type === "text" || + node.type === "bold" || + node.type === "italic" || + node.type === "strikethrough" || + node.type === "link" || + node.type === "image" || + node.type === "code_inline" || + node.type === "math_inline" || + node.type === "html_inline" || + node.type === "soft_break" || + node.type === "line_break" + ); +} + +export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray { + const blocks: MarkdownNode[] = []; + let inlineNodes: MarkdownNode[] = []; + const flushInlineNodes = () => { + if (inlineNodes.length === 0) { + return; + } + blocks.push({ type: "paragraph", children: inlineNodes }); + inlineNodes = []; + }; + + for (const child of node.children ?? []) { + if (isInlineNode(child)) { + inlineNodes.push(child); + continue; + } + + flushInlineNodes(); + blocks.push(child); + } + flushInlineNodes(); + return blocks; +} + +function appendListItem( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + marker: string, + depth: number, + markerColumnWidth: number, +): NativeMarkdownTextRun[] { + const firstLineHeadIndent = Math.max(0, depth - 1) * 20; + appendRun(runs, `${marker}\t`, { + ...EMPTY_CONTEXT, + role: "list-marker", + depth, + firstLineHeadIndent, + headIndent: firstLineHeadIndent + markerColumnWidth, + paragraphSpacing: 2, + }); + + const children = node.children ?? []; + let wroteInlineContent = false; + for (const child of children) { + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + if (child.type === "list") { + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: 1, + }); + } + appendList(runs, child, depth + 1); + wroteInlineContent = false; + continue; + } + if (isInlineNode(child)) { + appendNode(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + wroteInlineContent = true; + continue; + } + appendDocumentBlock(runs, child, depth); + wroteInlineContent = true; + } + + if (wroteInlineContent) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "list-break", + depth, + spacing: depth === 1 ? 4 : 2, + }); + } + return runs; +} + +function appendList( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const ordered = node.ordered ?? false; + const start = node.start ?? 1; + const children = node.children ?? []; + const markers = children.map((child, index) => + child.type === "task_list_item" + ? child.checked + ? "☑︎" + : "☐︎" + : ordered + ? `${start + index}.` + : depth % 3 === 2 + ? "◦" + : depth % 3 === 0 + ? "▪︎" + : "•", + ); + const markerWidth = ordered + ? Math.max(0, ...markers.map((marker) => Array.from(marker).length)) + : 0; + + for (const [index, child] of children.entries()) { + const marker = markers[index] ?? "•"; + const alignedMarker = + child.type === "task_list_item" + ? marker + : ordered + ? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}` + : marker; + const markerColumnWidth = + child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; + appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); + } + return runs; +} + +function appendQuoteBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + for (const [index, child] of (node.children ?? []).entries()) { + if (index > 0) { + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } + appendRun(runs, "│\u00a0", { + ...EMPTY_CONTEXT, + role: "quote-marker", + depth, + }); + if (child.type === "paragraph") { + appendInlineChildren(runs, child, { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + } else { + appendDocumentBlock(runs, child, depth); + } + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTableRow( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const cells = node.children ?? []; + for (const [index, cell] of cells.entries()) { + if (index > 0) { + appendRun(runs, "\u00a0│\u00a0", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + } + appendInlineChildren(runs, cell, { + ...EMPTY_CONTEXT, + role: "body", + bold: cell.isHeader ?? false, + depth, + }); + } + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + return runs; +} + +function appendTable( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth: number, +): NativeMarkdownTextRun[] { + const visit = (child: MarkdownNode) => { + if (child.type === "table_row") { + appendTableRow(runs, child, depth); + return; + } + for (const nested of child.children ?? []) { + visit(nested); + } + }; + visit(node); + return runs; +} + +function appendDocumentBlock( + runs: NativeMarkdownTextRun[], + node: MarkdownNode, + depth = 0, +): NativeMarkdownTextRun[] { + switch (node.type) { + case "document": { + const children = node.children ?? []; + for (const [index, child] of children.entries()) { + if (index > 0) { + const previous = children[index - 1]; + appendSpacer( + runs, + child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, + ); + } + appendDocumentBlock(runs, child, depth); + } + return runs; + } + case "heading": { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "heading", + headingLevel: node.level ?? 1, + depth, + }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "paragraph": { + const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } + case "list": + return appendList(runs, node, depth + 1); + case "blockquote": + return appendQuoteBlock(runs, node, depth); + case "code_block": { + if (node.language) { + appendRun(runs, `${node.language.toUpperCase()}\n`, { + ...EMPTY_CONTEXT, + role: "code-language", + code: true, + depth, + }); + } + const content = nodeTextContent(node); + appendRun(runs, content, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + if (!content.endsWith("\n")) { + appendBlockTerminator(runs, { + ...EMPTY_CONTEXT, + role: "code-block", + code: true, + depth, + }); + } + return runs; + } + case "horizontal_rule": + appendRun(runs, "────────────────────────\n", { + ...EMPTY_CONTEXT, + role: "divider", + depth, + }); + return runs; + case "table": + return appendTable(runs, node, depth); + case "html_block": + appendRun(runs, inlineHtmlText(nodeTextContent(node)), { + ...EMPTY_CONTEXT, + role: "body", + depth, + }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + case "math_block": + appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + default: + appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); + return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + } +} + +function containsRichBlock(node: MarkdownNode): boolean { + if ( + node.type === "code_block" || + node.type === "table" || + node.type === "image" || + node.type === "horizontal_rule" || + node.type === "html_block" || + node.type === "math_block" + ) { + return true; + } + return (node.children ?? []).some(containsRichBlock); +} + +export function nativeMarkdownDocumentChunks( + document: MarkdownNode, +): ReadonlyArray { + const chunks: NativeMarkdownDocumentChunk[] = []; + let selectableNodes: MarkdownNode[] = []; + + const flushSelectable = () => { + if (selectableNodes.length === 0) { + return; + } + const first = selectableNodes[0]; + const last = selectableNodes.at(-1); + chunks.push({ + kind: "selectable", + key: `selectable:${first?.beg ?? "start"}:${last?.end ?? "end"}`, + node: { + type: "document", + children: selectableNodes, + }, + }); + selectableNodes = []; + }; + + for (const [index, child] of (document.children ?? []).entries()) { + if (!containsRichBlock(child)) { + selectableNodes.push(child); + continue; + } + + flushSelectable(); + chunks.push({ + kind: "rich", + key: `rich:${child.type}:${child.beg ?? index}:${child.end ?? index}`, + node: child, + }); + } + flushSelectable(); + return chunks; +} + +function topLevelNodes(node: MarkdownNode): ReadonlyArray { + return node.type === "document" ? (node.children ?? []) : [node]; +} + +export function nativeMarkdownChunkSpacing( + previous: NativeMarkdownDocumentChunk | undefined, + current: NativeMarkdownDocumentChunk, +): number { + if (!previous) { + return 0; + } + + const previousLast = topLevelNodes(previous.node).at(-1); + const currentFirst = topLevelNodes(current.node)[0]; + + if (currentFirst?.type === "heading") { + return 20; + } + if (previousLast?.type === "heading") { + return 10; + } + if (previousLast?.type === "list" && currentFirst?.type === "list") { + return 12; + } + return 14; +} + +export function nativeMarkdownDocumentRuns( + node: MarkdownNode, + skills: ReadonlyArray = [], +): ReadonlyArray { + const runs = appendDocumentBlock([], node); + while (runs.length > 0) { + const lastIndex = runs.length - 1; + const last = runs[lastIndex]; + if (!last?.text.endsWith("\n")) { + break; + } + const text = last.text.slice(0, -1); + if (text.length === 0) { + runs.pop(); + } else { + runs[lastIndex] = { ...last, text }; + } + } + return decorateSkillRuns(runs, skills); +} diff --git a/apps/mobile/modules/t3-markdown-text/src/util.ts b/apps/mobile/modules/t3-markdown-text/src/util.ts new file mode 100644 index 00000000000..d9f33d3a2ef --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/util.ts @@ -0,0 +1,62 @@ +import { type StyleProp, StyleSheet, type TextStyle } from "react-native"; +import type { NativeFontWeight } from "./T3MarkdownTextRunNativeComponent"; + +export function flattenStyles(rootStyle: TextStyle, style: StyleProp) { + const flattenedStyle = StyleSheet.flatten([rootStyle, style]) as TextStyle; + return { + ...flattenedStyle, + fontWeight: fontWeightToNativeProp(flattenedStyle.fontWeight ?? "normal"), + backgroundColor: flattenedStyle.backgroundColor + ? flattenedStyle.backgroundColor + : "transparent", + shadowOffset: flattenedStyle.shadowOffset + ? flattenedStyle.shadowOffset + : { width: 0, height: 0 }, + }; +} + +// Codegen doesn't like using integer values for enums (c++ L) so we'll conver them to the proper native prop +// value before returning flattened styles. +function fontWeightToNativeProp(fontWeight: TextStyle["fontWeight"]): NativeFontWeight { + switch (fontWeight) { + case "normal": + return "normal"; + case "bold": + return "bold"; + case 100: + case "100": + case "ultralight": + return "ultraLight"; + case 200: + case "200": + return "ultraLight"; + case 300: + case "300": + case "light": + return "light"; + case 400: + case "400": + case "regular": + return "normal"; + case 500: + case "500": + case "medium": + return "medium"; + case 600: + case "600": + case "semibold": + return "semibold"; + case 700: + case "700": + return "semibold"; + case 800: + case "800": + return "bold"; + case 900: + case "900": + case "heavy": + return "heavy"; + default: + return "normal"; + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 4b08a338e12..d4dc47f18f5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@callstack/liquid-glass": "^0.7.1", - "@clerk/expo": "^3.3.0", + "@clerk/expo": "^3.4.1", "@effect/atom-react": "catalog:", "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/ui": "~56.0.8", @@ -54,6 +54,7 @@ "@shikijs/themes": "3.23.0", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/mobile-markdown-text": "file:./modules/t3-markdown-text", "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", @@ -61,6 +62,7 @@ "diff": "8.0.3", "effect": "catalog:", "expo": "^56.0.0", + "expo-asset": "~56.0.15", "expo-auth-session": "~56.0.12", "expo-build-properties": "~56.0.15", "expo-camera": "~56.0.7", @@ -74,6 +76,7 @@ "expo-haptics": "~56.0.3", "expo-image-picker": "~56.0.14", "expo-linking": "~56.0.12", + "expo-network": "~56.0.5", "expo-notifications": "~56.0.14", "expo-paste-input": "^0.1.15", "expo-router": "~56.2.7", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 136e141fdcf..db44e9904f8 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -5,7 +5,9 @@ import { DMSans_700Bold, useFonts, } from "@expo-google-fonts/dm-sans"; +import { usePathname } from "expo-router"; import Stack from "expo-router/stack"; +import { useCallback } from "react"; import { StatusBar, useColorScheme } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; @@ -14,21 +16,46 @@ import { useResolveClassNames } from "uniwind"; import { LoadingScreen } from "../components/LoadingScreen"; -import { - useRemoteEnvironmentBootstrap, - useRemoteEnvironmentState, -} from "../state/use-remote-environment-registry"; +import { useWorkspaceState } from "../state/workspace"; +import { useThreadOutboxDrain } from "../state/use-thread-outbox-drain"; import { RegistryContext } from "@effect/atom-react"; import { appAtomRegistry } from "../state/atom-registry"; import { CloudAuthProvider } from "../features/cloud/CloudAuthProvider"; +import { + ClerkSettingsSheetDetentProvider, + useClerkSettingsSheetDetent, +} from "../features/cloud/ClerkSettingsSheetDetent"; import { useAgentNotificationNavigation } from "../features/agent-awareness/notificationNavigation"; +import { useThemeColor } from "../lib/useThemeColor"; function AppNavigator() { - const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const pathname = usePathname(); + const clerkRouteIsActive = pathname === "/settings/auth"; + + return ( + + + + ); +} + +function AppNavigatorContent() { + const { state } = useWorkspaceState(); + const { collapse, isExpanded } = useClerkSettingsSheetDetent(); const colorScheme = useColorScheme(); - const statusBarBg = colorScheme === "dark" ? "#0a0a0a" : "#f2f2f7"; + const statusBarBg = useThemeColor("--color-status-bar"); const sheetStyle = useResolveClassNames("bg-sheet"); useAgentNotificationNavigation(); + useThreadOutboxDrain(); + + const handleSettingsTransitionEnd = useCallback( + (event: { data: { closing: boolean } }) => { + if (event.data.closing) { + collapse(); + } + }, + [collapse], + ); const newTaskScreenOptions = { contentStyle: sheetStyle, @@ -50,10 +77,10 @@ function AppNavigator() { const settingsSheetScreenOptions = { ...connectionSheetScreenOptions, - sheetAllowedDetents: [0.7], + sheetAllowedDetents: isExpanded ? [0.92] : [0.7], }; - if (isLoadingSavedConnection) { + if (state.isLoadingConnections) { return ; } @@ -61,7 +88,7 @@ function AppNavigator() { <> @@ -74,7 +101,11 @@ function AppNavigator() { headerShadowVisible: false, }} /> - + diff --git a/apps/mobile/src/app/connections/_layout.tsx b/apps/mobile/src/app/connections/_layout.tsx index 1bd507967fc..902b53cb15a 100644 --- a/apps/mobile/src/app/connections/_layout.tsx +++ b/apps/mobile/src/app/connections/_layout.tsx @@ -1,6 +1,6 @@ import Stack from "expo-router/stack"; -import { useColorScheme } from "react-native"; import { useResolveClassNames } from "uniwind"; +import { useThemeColor } from "../../lib/useThemeColor"; export const unstable_settings = { anchor: "index", @@ -8,9 +8,8 @@ export const unstable_settings = { export default function ConnectionsLayout() { const contentStyle = useResolveClassNames("bg-sheet"); - const isDark = useColorScheme() === "dark"; - const connSheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; - const headerTint = isDark ? "#f5f5f5" : "#262626"; + const connSheetBg = useThemeColor("--color-sheet"); + const headerTint = useThemeColor("--color-foreground"); return ( @@ -30,9 +35,13 @@ export default function HomeRouteScreen() { headerTitle: "", headerSearchBarOptions: { placeholder: "Search threads", + hideNavigationBar: false, onChangeText: (event) => { setSearchQuery(event.nativeEvent.text); }, + onCancelButtonPress: () => { + setSearchQuery(""); + }, allowToolbarIntegration: true, }, }} @@ -54,7 +63,7 @@ export default function HomeRouteScreen() { router.push("/connections/new")} + onOpenEnvironments={() => router.push("/settings/environments")} onSelectThread={(thread) => { router.push(buildThreadRoutePath(thread)); }} diff --git a/apps/mobile/src/app/new/_layout.tsx b/apps/mobile/src/app/new/_layout.tsx index 908a49a7f56..2113b13311c 100644 --- a/apps/mobile/src/app/new/_layout.tsx +++ b/apps/mobile/src/app/new/_layout.tsx @@ -1,8 +1,8 @@ import Stack from "expo-router/stack"; -import { useColorScheme } from "react-native"; import { useResolveClassNames } from "uniwind"; import { NewTaskFlowProvider } from "../../features/threads/new-task-flow-provider"; +import { useThemeColor } from "../../lib/useThemeColor"; export const unstable_settings = { anchor: "index", @@ -10,9 +10,8 @@ export const unstable_settings = { export default function NewTaskLayout() { const sheetStyle = useResolveClassNames("bg-sheet"); - const isDark = useColorScheme() === "dark"; - const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; - const headerTint = isDark ? "#f5f5f5" : "#262626"; + const sheetBg = useThemeColor("--color-sheet"); + const headerTint = useThemeColor("--color-foreground"); return ( diff --git a/apps/mobile/src/app/new/add-project/repository.tsx b/apps/mobile/src/app/new/add-project/repository.tsx index 2861dded1ad..7bf23a4955a 100644 --- a/apps/mobile/src/app/new/add-project/repository.tsx +++ b/apps/mobile/src/app/new/add-project/repository.tsx @@ -1,5 +1,5 @@ import { Stack, useLocalSearchParams } from "expo-router"; -import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime"; +import { addProjectRemoteSourceLabel } from "@t3tools/client-runtime/operations/projects"; import { AddProjectRepositoryScreen } from "../../../features/projects/AddProjectScreen"; diff --git a/apps/mobile/src/app/new/index.tsx b/apps/mobile/src/app/new/index.tsx index 76102d842f4..d557e56f0ba 100644 --- a/apps/mobile/src/app/new/index.tsx +++ b/apps/mobile/src/app/new/index.tsx @@ -8,16 +8,18 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import { useProjects, useThreadShells } from "../../state/entities"; +import type { WorkspaceState } from "../../state/workspaceModel"; +import { useWorkspaceState } from "../../state/workspace"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; -import { type RemoteCatalogState, useRemoteCatalog } from "../../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -function deriveProjectEmptyState(catalogState: RemoteCatalogState): { +function deriveProjectEmptyState(catalogState: WorkspaceState): { readonly title: string; readonly detail: string; readonly loading: boolean; } { - if (catalogState.isLoadingSavedConnections) { + if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", @@ -25,7 +27,7 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { }; } - if (!catalogState.hasSavedConnections) { + if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment before creating a task.", @@ -33,7 +35,12 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { }; } - if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error") && + !catalogState.hasLoadedShellSnapshot + ) { return { title: "Environment unavailable", detail: @@ -63,8 +70,10 @@ function deriveProjectEmptyState(catalogState: RemoteCatalogState): { } export default function NewTaskRoute() { - const { projects, state: catalogState, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { state: catalogState } = useWorkspaceState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const router = useRouter(); const insets = useSafeAreaInsets(); const chevronColor = useThemeColor("--color-chevron"); @@ -192,6 +201,9 @@ export default function NewTaskRoute() { bearerToken={ savedConnectionsById[item.environmentId]?.bearerToken ?? null } + dpopAccessToken={ + savedConnectionsById[item.environmentId]?.dpopAccessToken + } /> diff --git a/apps/mobile/src/app/settings/_layout.tsx b/apps/mobile/src/app/settings/_layout.tsx index 087e07ba5fb..86831d885f1 100644 --- a/apps/mobile/src/app/settings/_layout.tsx +++ b/apps/mobile/src/app/settings/_layout.tsx @@ -1,16 +1,27 @@ import Stack from "expo-router/stack"; -import { useColorScheme } from "react-native"; +import { useCallback } from "react"; import { useResolveClassNames } from "uniwind"; +import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; +import { useThemeColor } from "../../lib/useThemeColor"; + export const unstable_settings = { anchor: "index", }; export default function SettingsLayout() { + const { collapse } = useClerkSettingsSheetDetent(); const contentStyle = useResolveClassNames("bg-sheet"); - const isDark = useColorScheme() === "dark"; - const sheetBg = isDark ? "rgba(14, 14, 14, 0.98)" : "rgba(242, 242, 247, 0.98)"; - const headerTint = isDark ? "#f5f5f5" : "#262626"; + const sheetBg = useThemeColor("--color-sheet"); + const headerTint = useThemeColor("--color-foreground"); + const handleClerkRouteTransitionEnd = useCallback( + (event: { data: { closing: boolean } }) => { + if (event.data.closing) { + collapse(); + } + }, + [collapse], + ); return ( + ); } diff --git a/apps/mobile/src/app/settings/auth.tsx b/apps/mobile/src/app/settings/auth.tsx new file mode 100644 index 00000000000..de33207ccda --- /dev/null +++ b/apps/mobile/src/app/settings/auth.tsx @@ -0,0 +1,33 @@ +import { useAuth } from "@clerk/expo"; +import { AuthView, UserProfileView } from "@clerk/expo/native"; +import { Redirect, Stack } from "expo-router"; +import { View } from "react-native"; + +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; + +export default function SettingsAuthRouteScreen() { + return hasCloudPublicConfig() ? ( + + ) : ( + + ); +} + +function ConfiguredSettingsAuthRouteScreen() { + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + + return ( + <> + + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + + + ); +} diff --git a/apps/mobile/src/app/settings/environments.tsx b/apps/mobile/src/app/settings/environments.tsx index 8a40720089b..ffe963cc5a7 100644 --- a/apps/mobile/src/app/settings/environments.tsx +++ b/apps/mobile/src/app/settings/environments.tsx @@ -1,32 +1,38 @@ import { useAuth } from "@clerk/expo"; import { Stack, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; +import { + connectionStatusText, + type EnvironmentConnectionPhase, +} from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; -import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; -import * as Effect from "effect/Effect"; -import { useCallback, useMemo, useState } from "react"; -import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { useCallback, useState } from "react"; +import { + ActivityIndicator, + Pressable, + ScrollView, + Switch, + type NativeSyntheticEvent, + type TextLayoutEventData, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; -import { connectCloudEnvironment } from "../../features/cloud/linkEnvironment"; import { - hasCloudPublicConfig, - resolveRelayClerkTokenOptions, -} from "../../features/cloud/publicConfig"; -import { - useManagedRelayEnvironments, - useManagedRelayEnvironmentStatus, -} from "../../features/cloud/managedRelayState"; + type RelayEnvironmentView, + useConnectionController, +} from "../../features/connection/useConnectionController"; +import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { availableCloudEnvironmentPresentation } from "../../features/cloud/cloudEnvironmentPresentation"; import { ConnectionEnvironmentRow } from "../../features/connection/ConnectionEnvironmentRow"; +import { ConnectionStatusDot } from "../../features/connection/ConnectionStatusDot"; +import { splitEnvironmentSections } from "../../features/connection/environmentSections"; import { cn } from "../../lib/cn"; -import { mobileRuntime } from "../../lib/runtime"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useThemeColor } from "../../lib/useThemeColor"; -import { - connectSavedEnvironment, - useRemoteConnections, - useRemoteEnvironmentState, -} from "../../state/use-remote-environment-registry"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { useRemoteConnections } from "../../state/use-remote-environment-registry"; export default function SettingsEnvironmentsRouteScreen() { const { @@ -37,7 +43,11 @@ export default function SettingsEnvironmentsRouteScreen() { } = useRemoteConnections(); const router = useRouter(); const insets = useSafeAreaInsets(); - const hasEnvironments = connectedEnvironments.length > 0; + const { localEnvironments, connectedCloudEnvironments } = splitEnvironmentSections({ + connectedEnvironments, + cloudEnvironments: null, + }); + const hasLocalEnvironments = localEnvironments.length > 0; const [expandedId, setExpandedId] = useState(null); const accentColor = useThemeColor("--color-icon-muted"); @@ -69,9 +79,9 @@ export default function SettingsEnvironmentsRouteScreen() { paddingTop: 16, }} > - {hasEnvironments ? ( + {hasLocalEnvironments ? ( - {connectedEnvironments.map((environment, index) => ( + {localEnvironments.map((environment, index) => ( )} - {hasCloudPublicConfig() ? : null} + {hasCloudPublicConfig() ? ( + + ) : null} ); } -function ConfiguredCloudEnvironmentRows() { - const { getToken, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); - const { savedConnectionsById } = useRemoteEnvironmentState(); - const cloudEnvironmentsState = useManagedRelayEnvironments(); - const [connectingCloudEnvironmentId, setConnectingCloudEnvironmentId] = useState( - null, - ); +function ConfiguredCloudEnvironmentRows(props: { + readonly connectedCloudEnvironments: ReadonlyArray; + readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void; +}) { + const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const controller = useConnectionController(); const iconColor = useThemeColor("--color-icon"); - const availableCloudEnvironments = useMemo( - () => - (cloudEnvironmentsState.data ?? []).filter( - (environment) => savedConnectionsById[environment.environmentId] === undefined, - ), - [cloudEnvironmentsState.data, savedConnectionsById], - ); + const availableCloudEnvironments = controller.availableRelayEnvironments; + const [expandedErrorId, setExpandedErrorId] = useState(null); + const hasCloudRows = + props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0; const handleConnectCloudEnvironment = useCallback( - async (environment: RelayClientEnvironmentRecord) => { - setConnectingCloudEnvironmentId(environment.environmentId); - try { - const token = await getToken(resolveRelayClerkTokenOptions()); - if (!token) { - throw new Error("Sign in to T3 Cloud before connecting."); - } - await mobileRuntime.runPromise( - connectCloudEnvironment({ - clerkToken: token, - environment, - }).pipe(Effect.flatMap(connectSavedEnvironment)), - ); - } catch (error) { - Alert.alert( - "Connect failed", - error instanceof Error ? error.message : "Could not connect to this environment.", - ); - } finally { - setConnectingCloudEnvironmentId(null); - } + (entry: RelayEnvironmentView) => { + void controller.connectRelayEnvironment(entry.environment); }, - [getToken], + [controller], ); + const handleDisconnectCloudEnvironment = useCallback( + (environmentId: EnvironmentId) => { + void controller.removeEnvironment(environmentId); + }, + [controller], + ); + + const handleToggleCloudError = useCallback((environmentId: string) => { + setExpandedErrorId((current) => (current === environmentId ? null : environmentId)); + }, []); + if (!isSignedIn) return null; return ( @@ -164,11 +167,13 @@ function ConfiguredCloudEnvironmentRows() { T3 Cloud { + void controller.refreshRelayEnvironments(); + }} className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" > - {cloudEnvironmentsState.isPending ? ( + {controller.relayDiscovery.isRefreshing ? ( ) : ( @@ -176,33 +181,48 @@ function ConfiguredCloudEnvironmentRows() { - {availableCloudEnvironments.length > 0 ? ( + {hasCloudRows ? ( - {availableCloudEnvironments.map((environment, index) => ( - ( + props.onReconnectEnvironment(environment.environmentId)} + onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)} + errorExpanded={expandedErrorId === environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environmentId)} + /> + ))} + {availableCloudEnvironments.map((environment, index) => ( + 0 || index !== 0} onConnect={() => handleConnectCloudEnvironment(environment)} + errorExpanded={expandedErrorId === environment.environment.environmentId} + onToggleError={() => handleToggleCloudError(environment.environment.environmentId)} /> ))} - ) : cloudEnvironmentsState.data === null ? ( + ) : controller.relayDiscovery.isRefreshing ? ( Loading linked cloud environments. - ) : cloudEnvironmentsState.error ? ( + ) : controller.relayDiscovery.error ? ( Could not load T3 Cloud environments - {cloudEnvironmentsState.error} + {controller.relayDiscovery.error} + {controller.relayDiscovery.errorTraceId ? ( + + ) : null} ) : ( @@ -215,23 +235,124 @@ function ConfiguredCloudEnvironmentRows() { ); } +function ConnectedCloudEnvironmentRow(props: { + readonly environment: ConnectedEnvironmentSummary; + readonly borderTop: boolean; + readonly errorExpanded: boolean; + readonly onConnect: () => void; + readonly onDisconnect: () => void; + readonly onToggleError: () => void; +}) { + return ( + { + if (enabled) { + props.onConnect(); + return; + } + props.onDisconnect(); + }} + onToggleError={props.onToggleError} + value={props.environment.connectionState !== "available"} + /> + ); +} + function CloudEnvironmentRow(props: { - readonly environment: RelayClientEnvironmentRecord; + readonly environment: RelayEnvironmentView; readonly borderTop: boolean; - readonly isConnecting: boolean; + readonly errorExpanded: boolean; readonly onConnect: () => void; + readonly onToggleError: () => void; }) { - const mutedColor = useThemeColor("--color-icon-muted"); - const statusState = useManagedRelayEnvironmentStatus(props.environment); - const status = statusState.data; - const disabled = props.isConnecting; - const statusText = - status === null - ? (statusState.error ?? (statusState.isPending ? "Checking status..." : "Status unavailable")) - : status.status === "online" - ? "Online" - : (status.error ?? "Offline"); + const presentation = availableCloudEnvironmentPresentation({ + isStatusPending: props.environment.availability === "checking", + status: props.environment.status, + statusError: props.environment.error, + statusErrorTraceId: props.environment.traceId, + }); + return ( + { + if (enabled) { + props.onConnect(); + } + }} + onToggleError={props.onToggleError} + statusText={presentation.statusText} + value={false} + /> + ); +} + +function CloudEnvironmentRowShell(props: { + readonly borderTop: boolean; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly disabled?: boolean; + readonly errorExpanded: boolean; + readonly label: string; + readonly onToggleError: () => void; + readonly onValueChange: (enabled: boolean) => void; + readonly statusText?: string; + readonly value: boolean; +}) { + const activeTrack = String(useThemeColor("--color-switch-active")); + const track = String(useThemeColor("--color-secondary-border")); + const chevron = useThemeColor("--color-chevron"); + const isRetrying = + props.connectionState === "connecting" || props.connectionState === "reconnecting"; + const shouldPulse = isRetrying; + const statusText = + props.statusText ?? + connectionStatusText({ + phase: props.connectionState, + error: props.connectionError, + traceId: props.connectionErrorTraceId, + }); + const statusClassName = props.connectionError + ? "text-rose-500 dark:text-rose-400" + : "text-foreground-muted"; + const [errorMeasurement, setErrorMeasurement] = useState<{ + readonly text: string; + readonly lineCount: number; + } | null>(null); + const errorTraceId = props.connectionErrorTraceId; + const measuredErrorText = errorTraceId ? `${statusText} Trace ID: ${errorTraceId}` : statusText; + const errorLineCount = + errorMeasurement?.text === measuredErrorText ? errorMeasurement.lineCount : 0; + const errorCanExpand = props.connectionError !== null && errorLineCount > 1; + const isErrorExpanded = errorCanExpand && props.errorExpanded; + const StatusContainer = errorCanExpand ? Pressable : View; + const onMeasuredErrorTextLayout = useCallback( + (event: NativeSyntheticEvent) => { + if (!props.connectionError) { + return; + } + const nextLineCount = event.nativeEvent.lines.length; + setErrorMeasurement((currentMeasurement) => + currentMeasurement?.text === measuredErrorText && + currentMeasurement.lineCount === nextLineCount + ? currentMeasurement + : { text: measuredErrorText, lineCount: nextLineCount }, + ); + }, + [measuredErrorText, props.connectionError], + ); return ( - - - - - {props.environment.label} - - - {props.environment.endpoint.httpBaseUrl} - - - {statusText} - + + + + {props.label} + + + {props.connectionError ? ( + + {measuredErrorText} + + ) : null} + + + {statusText} + {errorTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(errorTraceId); + }} + onPress={(event) => { + event.stopPropagation(); + }} + style={{ textDecorationStyle: "dotted" }} + > + {errorTraceId} + + + ) : null} + + {errorCanExpand ? ( + + ) : null} + - - - {props.isConnecting ? "Connecting" : "Connect"} - - + ); } + +function CopyTraceIdButton(props: { readonly traceId: string }) { + const iconColor = useThemeColor("--color-icon"); + + return ( + { + copyTextWithHaptic(props.traceId); + }} + className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" + > + + Copy trace ID + + ); +} diff --git a/apps/mobile/src/app/settings/index.tsx b/apps/mobile/src/app/settings/index.tsx index b25de626867..4b5c3e8da73 100644 --- a/apps/mobile/src/app/settings/index.tsx +++ b/apps/mobile/src/app/settings/index.tsx @@ -1,4 +1,4 @@ -import { useAuth, useUser, useUserProfileModal } from "@clerk/expo"; +import { useAuth, useUser } from "@clerk/expo"; import * as Notifications from "expo-notifications"; import { Link, Stack, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; @@ -13,14 +13,15 @@ import { setLiveActivityUpdatesEnabled } from "../../features/agent-awareness/li import { requestAgentNotificationPermission } from "../../features/agent-awareness/notificationPermissions"; import { refreshAgentAwarenessRegistration } from "../../features/agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../../features/cloud/managedRelayState"; +import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions, } from "../../features/cloud/publicConfig"; -import { mobileRuntime } from "../../lib/runtime"; +import { runtime } from "../../lib/runtime"; import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -31,7 +32,7 @@ export default function SettingsRouteScreen() { function LocalSettingsRouteScreen() { const insets = useSafeAreaInsets(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const environmentCount = Object.keys(savedConnectionsById).length; return ( @@ -66,10 +67,10 @@ function LocalSettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { const insets = useSafeAreaInsets(); const { push } = useRouter(); + const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); - const { isAvailable: isUserProfileModalAvailable, presentUserProfile } = useUserProfileModal(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const { savedConnectionsById } = useSavedRemoteConnections(); const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); @@ -115,7 +116,7 @@ function ConfiguredSettingsRouteScreen() { const requestNotifications = useCallback(async () => { try { - const result = await mobileRuntime.runPromise( + const result = await runtime.runPromise( requestAgentNotificationPermission.pipe( Effect.tap((permission) => permission.type === "granted" ? refreshAgentAwarenessRegistration() : Effect.void, @@ -185,7 +186,7 @@ function ConfiguredSettingsRouteScreen() { return; } - await mobileRuntime.runPromise( + await runtime.runPromise( setLiveActivityUpdatesEnabled({ enabled: true, clerkToken: token, @@ -235,7 +236,7 @@ function ConfiguredSettingsRouteScreen() { void (async () => { try { const token = isSignedIn ? await getToken(resolveRelayClerkTokenOptions()) : null; - await mobileRuntime.runPromise( + await runtime.runPromise( setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: token, @@ -266,15 +267,9 @@ function ConfiguredSettingsRouteScreen() { push("/settings/waitlist"); return; } - if (isUserProfileModalAvailable) { - void presentUserProfile(); - return; - } - Alert.alert( - "T3 Cloud unavailable", - "Native T3 Cloud account management is not available in this build.", - ); - }, [isLoaded, isSignedIn, isUserProfileModalAvailable, presentUserProfile, push]); + expandClerkSheet(); + push("/settings/auth"); + }, [expandClerkSheet, isLoaded, isSignedIn, push]); return ( @@ -340,7 +335,7 @@ type SymbolName = ComponentProps["name"]; function SettingsSection(props: { readonly title: string; readonly children: ReactNode }) { return ( - {props.title} + {props.title} - {props.label} - {props.value ? ( - - {props.value} - - ) : null} + + {props.label} + + + {props.value ? ( + + {props.value} + + ) : null} + { + if (isLoaded && isSignedIn) { + router.replace("/settings"); + } + }, [isLoaded, isSignedIn, router]), + ); return ( <> @@ -31,7 +43,12 @@ function ConfiguredSettingsWaitlistRouteScreen() { keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} > - void presentAuth()} /> + { + expand(); + router.push("/settings/auth"); + }} + /> ); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx new file mode 100644 index 00000000000..0c596e29232 --- /dev/null +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -0,0 +1,6 @@ +export { ComposerEditor } from "../native/T3ComposerEditor"; +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "../native/T3ComposerEditor"; diff --git a/apps/mobile/src/components/CopyTextButton.tsx b/apps/mobile/src/components/CopyTextButton.tsx new file mode 100644 index 00000000000..712b272a909 --- /dev/null +++ b/apps/mobile/src/components/CopyTextButton.tsx @@ -0,0 +1,68 @@ +import { SymbolView } from "expo-symbols"; +import { memo, useEffect, useRef, useState } from "react"; +import { Pressable, type ColorValue } from "react-native"; + +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +const COPY_FEEDBACK_DURATION_MS = 1200; + +export const CopyTextButton = memo(function CopyTextButton(props: { + readonly accessibilityLabel: string; + readonly text: string; + readonly tintColor: ColorValue; + readonly copiedTintColor?: ColorValue; + readonly backgroundColor?: ColorValue; + readonly borderColor?: ColorValue; + readonly iconSize?: number; + readonly buttonSize?: number; +}) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }, + [], + ); + + return ( + { + copyTextWithHaptic(props.text); + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_FEEDBACK_DURATION_MS); + }} + style={({ pressed }) => ({ + width: props.buttonSize ?? 30, + height: props.buttonSize ?? 30, + alignItems: "center", + justifyContent: "center", + borderRadius: 9, + borderWidth: props.borderColor ? 1 : 0, + borderColor: props.borderColor, + backgroundColor: props.backgroundColor, + opacity: pressed ? 0.52 : 1, + })} + > + + + ); +}); diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx index f7cc49c368e..836a7cffbd7 100644 --- a/apps/mobile/src/components/GlassSafeAreaView.tsx +++ b/apps/mobile/src/components/GlassSafeAreaView.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; -import { useColorScheme, View, type StyleProp, type ViewStyle } from "react-native"; +import { View, type StyleProp, type ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useThemeColor } from "../lib/useThemeColor"; import { GlassSurface } from "./GlassSurface"; @@ -17,14 +18,16 @@ export function GlassSafeAreaView({ rightSlot, style, }: GlassSafeAreaViewProps) { - const isDarkMode = useColorScheme() === "dark"; const insets = useSafeAreaInsets(); + const headerColor = useThemeColor("--color-header"); + const headerBorderColor = useThemeColor("--color-header-border"); + const glassTint = useThemeColor("--color-glass-tint"); const headerPaddingTop = insets.top + 16; const surfaceStyle = { borderRadius: 0, - backgroundColor: isDarkMode ? "rgba(10,10,10,0.97)" : "rgba(255,255,255,0.97)", + backgroundColor: headerColor, borderBottomWidth: 1, - borderBottomColor: isDarkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)", + borderBottomColor: headerBorderColor, } as const; return ( @@ -32,7 +35,7 @@ export function GlassSafeAreaView({ diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index 32297d8d9d2..676196aca70 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -2,6 +2,7 @@ import { SymbolView } from "expo-symbols"; import { useState } from "react"; import { Image, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; +import { useRemoteHttpHeaders } from "../state/remote-http"; /* ─── Favicon cache (matches web pattern) ────────────────────────────── */ const loadedFaviconUrls = new Set(); @@ -13,6 +14,7 @@ export function ProjectFavicon(props: { readonly httpBaseUrl?: string | null; readonly workspaceRoot?: string | null; readonly bearerToken?: string | null; + readonly dpopAccessToken?: string; }) { const size = props.size ?? 42; const iconMuted = useThemeColor("--color-icon-subtle"); @@ -21,6 +23,11 @@ export function ProjectFavicon(props: { props.httpBaseUrl && props.workspaceRoot ? `${props.httpBaseUrl}/api/project-favicon?cwd=${encodeURIComponent(props.workspaceRoot)}` : null; + const request = useRemoteHttpHeaders({ + url: faviconUrl, + bearerToken: props.bearerToken ?? null, + ...(props.dpopAccessToken ? { dpopAccessToken: props.dpopAccessToken } : {}), + }); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => faviconUrl && loadedFaviconUrls.has(faviconUrl) ? "loaded" : "loading", @@ -43,13 +50,11 @@ export function ProjectFavicon(props: { ) : null} {/* Favicon image (hidden until loaded) */} - {faviconUrl ? ( + {faviconUrl && request.isReady ? ( diff --git a/apps/mobile/src/components/VscodeEntryIcon.tsx b/apps/mobile/src/components/VscodeEntryIcon.tsx new file mode 100644 index 00000000000..88617bcee2c --- /dev/null +++ b/apps/mobile/src/components/VscodeEntryIcon.tsx @@ -0,0 +1,25 @@ +import { SymbolView } from "expo-symbols"; +import { Image, type ImageStyle, type StyleProp } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; + +export function VscodeEntryIcon(props: { + readonly path: string; + readonly kind: "file" | "directory"; + readonly size?: number; + readonly style?: StyleProp; +}) { + const size = props.size ?? 16; + if (props.kind === "directory") { + return ; + } + + return ( + + ); +} diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts new file mode 100644 index 00000000000..a05e6e38020 --- /dev/null +++ b/apps/mobile/src/connection/catalog-store.ts @@ -0,0 +1,117 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, + EMPTY_CONNECTION_CATALOG_DOCUMENT, +} from "@t3tools/client-runtime/platform"; +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { migrateLegacyConnectionCatalog } from "./migration"; + +export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; +export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => catalogError("decode", cause), + }); + return yield* Effect.fromResult( + Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), + ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); +}); + +const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( + catalog: ConnectionCatalogDocumentType, +) { + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), + ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); + return JSON.stringify(encoded); +}); + +interface CatalogStore { + readonly read: Effect.Effect; + readonly update: ( + transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, + ) => Effect.Effect; +} + +export interface SecureCatalogStorage { + readonly getItem: (key: string) => Effect.Effect; + readonly setItem: (key: string, value: string) => Effect.Effect; + readonly deleteItem: (key: string) => Effect.Effect; +} + +export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( + storage: SecureCatalogStorage, +) { + const state = yield* Ref.make>(Option.none()); + const lock = yield* Semaphore.make(1); + + const loadUnlocked = Effect.fn("mobile.connectionStorage.loadCatalog")(function* () { + const cached = yield* Ref.get(state); + if (Option.isSome(cached)) { + return cached.value; + } + const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); + let catalog: ConnectionCatalogDocumentType; + if (raw !== null && raw.trim() !== "") { + catalog = yield* decodeCatalog(raw).pipe( + Effect.catch((error) => + Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( + Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), + Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), + ), + ), + ); + } else { + const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); + catalog = + legacyRaw === null || legacyRaw.trim() === "" + ? EMPTY_CONNECTION_CATALOG_DOCUMENT + : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe( + Effect.mapError((cause) => catalogError("migrate", cause)), + Effect.catch((error) => + Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( + Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), + ), + ), + ); + if (legacyRaw !== null && legacyRaw.trim() !== "") { + const encoded = yield* encodeCatalog(catalog); + yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); + } + } + yield* Ref.set(state, Option.some(catalog)); + return catalog; + }); + + const read = lock.withPermits(1)(loadUnlocked()); + const update: CatalogStore["update"] = Effect.fn("mobile.connectionStorage.updateCatalog")( + function* (transform) { + yield* lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* loadUnlocked()); + const encoded = yield* encodeCatalog(next); + yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* Ref.set(state, Option.some(next)); + }), + ); + }, + ); + + return { read, update } satisfies CatalogStore; +}); diff --git a/apps/mobile/src/connection/catalog.ts b/apps/mobile/src/connection/catalog.ts new file mode 100644 index 00000000000..971fa891106 --- /dev/null +++ b/apps/mobile/src/connection/catalog.ts @@ -0,0 +1,5 @@ +import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections"; + +import { connectionAtomRuntime } from "./runtime"; + +export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/connection/migration.test.ts b/apps/mobile/src/connection/migration.test.ts new file mode 100644 index 00000000000..5cb17bd5bf7 --- /dev/null +++ b/apps/mobile/src/connection/migration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { migrateLegacyConnectionCatalog } from "./migration"; + +describe("migrateLegacyConnectionCatalog", () => { + it.effect("migrates bearer and relay-managed connections into the new catalog", () => + Effect.gen(function* () { + const bearerEnvironmentId = EnvironmentId.make("bearer-environment"); + const relayEnvironmentId = EnvironmentId.make("relay-environment"); + const catalog = yield* migrateLegacyConnectionCatalog( + JSON.stringify({ + connections: [ + { + environmentId: bearerEnvironmentId, + environmentLabel: "Local Mac", + pairingUrl: "https://local.example.test/pair", + displayUrl: "https://local.example.test", + httpBaseUrl: "https://local.example.test", + wsBaseUrl: "wss://local.example.test", + bearerToken: "bearer-token", + authenticationMethod: "bearer", + }, + { + environmentId: relayEnvironmentId, + environmentLabel: "Cloud Mac", + pairingUrl: "https://relay.example.test", + displayUrl: "https://relay.example.test", + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test", + bearerToken: null, + authenticationMethod: "dpop", + relayManaged: true, + }, + ], + }), + ); + + expect(catalog.targets).toHaveLength(2); + expect( + catalog.targets.find((target) => target.environmentId === bearerEnvironmentId)?._tag, + ).toBe("BearerConnectionTarget"); + expect( + catalog.targets.find((target) => target.environmentId === relayEnvironmentId)?._tag, + ).toBe("RelayConnectionTarget"); + expect(catalog.profiles).toHaveLength(1); + expect(catalog.credentials).toHaveLength(1); + expect(catalog.credentials[0]?.credential).toMatchObject({ + _tag: "BearerConnectionCredential", + token: "bearer-token", + }); + }), + ); + + it.effect("drops invalid legacy bearer entries without credentials", () => + Effect.gen(function* () { + const catalog = yield* migrateLegacyConnectionCatalog( + JSON.stringify({ + connections: [ + { + environmentId: EnvironmentId.make("invalid-bearer"), + environmentLabel: "Invalid", + pairingUrl: "https://invalid.example.test/pair", + displayUrl: "https://invalid.example.test", + httpBaseUrl: "https://invalid.example.test", + wsBaseUrl: "wss://invalid.example.test", + bearerToken: null, + authenticationMethod: "bearer", + }, + ], + }), + ); + + expect(catalog.targets).toEqual([]); + }), + ); +}); diff --git a/apps/mobile/src/connection/migration.ts b/apps/mobile/src/connection/migration.ts new file mode 100644 index 00000000000..6f324c9ff15 --- /dev/null +++ b/apps/mobile/src/connection/migration.ts @@ -0,0 +1,110 @@ +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + RelayConnectionRegistration, + RelayConnectionTarget, + BearerConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { + type ConnectionCatalogDocument, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + registerConnectionInCatalog, +} from "@t3tools/client-runtime/platform"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +const LegacySavedRemoteConnection = Schema.Struct({ + environmentId: EnvironmentId, + environmentLabel: Schema.String, + pairingUrl: Schema.String, + displayUrl: Schema.String, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + bearerToken: Schema.NullOr(Schema.String), + authenticationMethod: Schema.optionalKey(Schema.Literals(["bearer", "dpop"])), + dpopAccessToken: Schema.optionalKey(Schema.String), + relayManaged: Schema.optionalKey(Schema.Literal(true)), +}); + +const LegacyConnectionDocument = Schema.Struct({ + connections: Schema.optionalKey(Schema.Array(LegacySavedRemoteConnection)), +}); +const decodeLegacyConnectionDocument = Schema.decodeUnknownEffect(LegacyConnectionDocument); + +export class LegacyConnectionMigrationError extends Schema.TaggedErrorClass()( + "LegacyConnectionMigrationError", + { + message: Schema.String, + }, +) {} + +function isRelayManaged(connection: typeof LegacySavedRemoteConnection.Type): boolean { + return connection.relayManaged === true || connection.authenticationMethod === "dpop"; +} + +function migrateConnection( + document: ConnectionCatalogDocument, + connection: typeof LegacySavedRemoteConnection.Type, +): ConnectionCatalogDocument { + if (isRelayManaged(connection)) { + return registerConnectionInCatalog( + document, + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + }), + }), + ); + } + + if (connection.bearerToken === null || connection.bearerToken.trim() === "") { + return document; + } + + const connectionId = `bearer:${connection.environmentId}`; + return registerConnectionInCatalog( + document, + new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: connection.environmentId, + label: connection.environmentLabel, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: connection.environmentId, + label: connection.environmentLabel, + httpBaseUrl: connection.httpBaseUrl, + wsBaseUrl: connection.wsBaseUrl, + }), + credential: new BearerConnectionCredential({ + token: connection.bearerToken, + }), + }), + ); +} + +export const migrateLegacyConnectionCatalog = Effect.fn( + "mobile.connectionMigration.migrateCatalog", +)(function* (raw: string) { + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => + new LegacyConnectionMigrationError({ + message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, + }), + }); + const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe( + Effect.mapError( + (cause) => + new LegacyConnectionMigrationError({ + message: `Could not decode the legacy mobile connection catalog: ${String(cause)}`, + }), + ), + ); + + return (legacy.connections ?? []).reduce(migrateConnection, EMPTY_CONNECTION_CATALOG_DOCUMENT); +}); diff --git a/apps/mobile/src/connection/onboarding.ts b/apps/mobile/src/connection/onboarding.ts new file mode 100644 index 00000000000..77633dcd98e --- /dev/null +++ b/apps/mobile/src/connection/onboarding.ts @@ -0,0 +1,24 @@ +import { ConnectionOnboarding } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "./runtime"; + +export const connectPairingUrl = connectionAtomRuntime + .fn()((pairingUrl) => + ConnectionOnboarding.pipe( + Effect.flatMap((onboarding) => onboarding.registerPairing({ pairingUrl })), + ), + ) + .pipe(Atom.withLabel("mobile:connection:connect-pairing-url")); + +export const updateBearerConnection = connectionAtomRuntime + .fn<{ + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + }>()((input) => + ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.updateBearer(input))), + ) + .pipe(Atom.withLabel("mobile:connection:update-bearer")); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts new file mode 100644 index 00000000000..b9e3709894e --- /dev/null +++ b/apps/mobile/src/connection/platform.ts @@ -0,0 +1,207 @@ +import { + ClientPresentation, + CloudSession, + EnvironmentOwnedDataCleanup, + PlatformConnectionSource, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime/platform"; +import { + ConnectionBlockedError, + ConnectionTransientError, + ConnectionWakeups, + Connectivity, +} from "@t3tools/client-runtime/connection"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; +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 Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as Network from "expo-network"; +import { AppState } from "react-native"; + +import { authClientMetadata } from "../lib/authClientMetadata"; +import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import { appAtomRegistry } from "../state/atom-registry"; +import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; +import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; +import { connectionStorageLayer } from "./storage"; + +function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { + if (state.isConnected === false || state.isInternetReachable === false) { + return "offline"; + } + if (state.isConnected === true) { + return "online"; + } + return "unknown"; +} + +const connectivityLayer = Layer.succeed( + Connectivity, + Connectivity.of({ + status: Effect.tryPromise({ + try: () => Network.getNetworkStateAsync(), + catch: () => undefined, + }).pipe( + Effect.match({ + onFailure: () => "unknown" as const, + onSuccess: networkStatus, + }), + ), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => + Network.addNetworkStateListener((state) => { + Queue.offerUnsafe(queue, networkStatus(state)); + }), + ), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), + }), +); + +const wakeupsLayer = Layer.succeed( + ConnectionWakeups, + ConnectionWakeups.of({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => + Effect.acquireRelease( + Effect.sync(() => + AppState.addEventListener("change", (state) => { + if (state === "active") { + Queue.offerUnsafe(queue, "application-active"); + } + }), + ), + (subscription) => Effect.sync(() => subscription.remove()), + ).pipe(Effect.asVoid), + ), + Stream.callback<"credentials-changed">((queue) => + Effect.acquireRelease( + Effect.sync(() => + appAtomRegistry.subscribe(managedRelaySessionAtom, () => { + Queue.offerUnsafe(queue, "credentials-changed"); + }), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + ), + ), + }), +); + +const capabilitiesLayer = Layer.succeedContext( + Context.make( + CloudSession, + CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "Sign in to T3 Cloud to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + message: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The T3 Cloud session is unavailable.", + }); + } + return token; + }), + }), + ).pipe( + Context.add( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ + deviceId: Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not load the mobile device identity: ${String(cause)}`, + }), + }).pipe(Effect.map(Option.some)), + }), + ), + Context.add( + ClientPresentation, + ClientPresentation.of({ + metadata: authClientMetadata(), + scopes: AuthStandardClientScopes, + }), + ), + Context.add( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + message: "SSH environments are only available in the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + message: "SSH environments are only available in the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), + ), +); + +const platformConnectionSourceLayer = Layer.succeed( + PlatformConnectionSource, + PlatformConnectionSource.of({ + registrations: Stream.empty, + }), +); + +const environmentOwnedDataCleanupLayer = Layer.succeed( + EnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Effect.all( + [ + Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), + Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), + ], + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + cause, + }), + ), + ), + }), +); + +export const connectionPlatformLayer = Layer.mergeAll( + connectionStorageLayer, + connectivityLayer, + wakeupsLayer, + capabilitiesLayer, + platformConnectionSourceLayer, + environmentOwnedDataCleanupLayer, +); diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts new file mode 100644 index 00000000000..3b1eade0818 --- /dev/null +++ b/apps/mobile/src/connection/runtime.ts @@ -0,0 +1,16 @@ +import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import { runtimeContextLayer } from "../lib/runtime"; +import { connectionPlatformLayer } from "./platform"; + +const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( + Layer.provide(runtimeContextLayer), +); + +export const connectionLayer = clientConnectionLayer.pipe( + Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +); + +export const connectionAtomRuntime = Atom.runtime(connectionLayer); diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts new file mode 100644 index 00000000000..8a1e08469e7 --- /dev/null +++ b/apps/mobile/src/connection/storage.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + CONNECTION_CATALOG_KEY, + LEGACY_CONNECTIONS_KEY, + makeCatalogStore, + type SecureCatalogStorage, +} from "./catalog-store"; + +function makeStorage(initial: Readonly>) { + const values = new Map(Object.entries(initial)); + const deleted: Array = []; + const storage: SecureCatalogStorage = { + getItem: (key) => Effect.sync(() => values.get(key) ?? null), + setItem: (key, value) => + Effect.sync(() => { + values.set(key, value); + }), + deleteItem: (key) => + Effect.sync(() => { + deleted.push(key); + values.delete(key); + }), + }; + return { deleted, storage, values }; +} + +describe("mobile connection catalog storage", () => { + it.effect("recovers from a corrupt current catalog", () => + Effect.gen(function* () { + const memory = makeStorage({ + [CONNECTION_CATALOG_KEY]: "{not-json", + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toEqual([]); + expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); + }), + ); + + it.effect("replaces and removes a corrupt legacy catalog", () => + Effect.gen(function* () { + const memory = makeStorage({ + [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), + }); + const catalog = yield* makeCatalogStore(memory.storage); + + expect((yield* catalog.read).targets).toEqual([]); + expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); + expect(memory.values.has(CONNECTION_CATALOG_KEY)).toBe(true); + }), + ); +}); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts new file mode 100644 index 00000000000..5754d655633 --- /dev/null +++ b/apps/mobile/src/connection/storage.ts @@ -0,0 +1,432 @@ +import { + ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EnvironmentCacheStore, + registerConnectionInCatalog, + removeConnectionFromCatalog, + removeCatalogValue, + replaceCatalogValue, +} from "@t3tools/client-runtime/platform"; +import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { + ConnectionCredentialStore, + ConnectionProfileStore, + ConnectionTransientError, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + OrchestrationThread, + OrchestrationShellSnapshot, + ThreadId, +} from "@t3tools/contracts"; +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 Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; + +const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; +const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; + +const StoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, +}); + +const StoredThreadSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + threadId: ThreadId, + thread: OrchestrationThread, +}); + +const LegacyStoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + snapshotReceivedAt: Schema.String, + snapshot: OrchestrationShellSnapshot, +}); + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +function shellPersistenceError( + operation: + | "load-shell" + | "save-shell" + | "load-thread" + | "save-thread" + | "remove-thread" + | "clear-environment", + cause: unknown, +) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +function threadSnapshotFileName(threadId: ThreadId): string { + return `${encodeURIComponent(threadId)}.json`; +} + +const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( + function* ( + environmentId: EnvironmentId, + operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", + ) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory( + Paths.document, + THREAD_SNAPSHOT_CACHE_DIRECTORY, + encodeURIComponent(environmentId), + ); + if (operation !== "clear-environment") { + directory.create({ idempotent: true, intermediates: true }); + } + return directory; + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); + }, +); + +const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( + environmentId: EnvironmentId, + threadId: ThreadId, + operation: "load-thread" | "save-thread" | "remove-thread", +) { + const { File } = yield* Effect.promise(() => import("expo-file-system")); + return new File( + yield* threadSnapshotDirectory(environmentId, operation), + threadSnapshotFileName(threadId), + ); +}); + +function targetPersistenceError( + operation: "list-targets" | "register-connection" | "remove-connection", + error: ConnectionTransientError, +) { + return new ConnectionPersistenceError({ + operation, + message: error.message, + }); +} + +const secureCatalogStorage: SecureCatalogStorage = { + getItem: (key) => + Effect.tryPromise({ + try: () => SecureStore.getItemAsync(key), + catch: (cause) => catalogError("load", cause), + }), + setItem: (key, value) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(key, value), + catch: (cause) => catalogError("save", cause), + }), + deleteItem: (key) => + Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(key), + catch: (cause) => catalogError("delete", cause), + }), +}; + +function shellSnapshotFileName(environmentId: EnvironmentId): string { + return `${encodeURIComponent(environmentId)}.json`; +} + +const shellSnapshotFileInDirectory = Effect.fn( + "mobile.connectionStorage.shellSnapshotFileInDirectory", +)(function* ( + environmentId: EnvironmentId, + operation: "load-shell" | "save-shell" | "clear-environment", + directoryName: string, +) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, File, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, directoryName); + directory.create({ idempotent: true, intermediates: true }); + return new File(directory, shellSnapshotFileName(environmentId)); + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); +}); + +const shellSnapshotFile = ( + environmentId: EnvironmentId, + operation: "load-shell" | "save-shell" | "clear-environment", +) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); + +const legacyShellSnapshotFile = ( + environmentId: EnvironmentId, + operation: "load-shell" | "clear-environment", +) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); + +export const connectionStorageLayer = Layer.effectContext( + Effect.gen(function* () { + const catalog = yield* makeCatalogStore(secureCatalogStorage); + + const targetStore = ConnectionTargetStore.of({ + list: catalog.read.pipe( + Effect.map((document) => document.targets), + Effect.mapError((error) => targetPersistenceError("list-targets", error)), + ), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + catalog + .update((document) => registerConnectionInCatalog(document, registration)) + .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + remove: (target) => + catalog + .update((document) => removeConnectionFromCatalog(document, target)) + .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), + }); + const profileStore = ConnectionProfileStore.of({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.profiles.find((candidate) => candidate.connectionId === connectionId), + ), + ), + ), + put: (profile) => + catalog.update((document) => ({ + ...document, + profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + profiles: removeCatalogValue( + document.profiles, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const credentialStore = ConnectionCredentialStore.of({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.credentials.find((entry) => entry.connectionId === connectionId)?.credential, + ), + ), + ), + put: (connectionId, credential) => + catalog.update((document) => ({ + ...document, + credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, { + connectionId, + credential, + }), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + credentials: removeCatalogValue( + document.credentials, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const remoteTokenStore = RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.remoteDpopTokens.find((token) => token.environmentId === environmentId), + ), + ), + ), + put: (token) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: replaceCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + token, + ), + })), + remove: (environmentId) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + environmentId, + ), + })), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "load-shell"); + if (file.exists) { + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const stored = yield* Effect.fromResult( + Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + return stored.environmentId === environmentId + ? Option.some(stored.snapshot) + : Option.none(); + } + + const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); + if (!legacyFile.exists) { + return Option.none(); + } + const legacyRaw = yield* Effect.tryPromise({ + try: () => legacyFile.text(), + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const legacyParsed = yield* Effect.try({ + try: () => JSON.parse(legacyRaw) as unknown, + catch: (cause) => shellPersistenceError("load-shell", cause), + }); + const legacyStored = yield* Effect.fromResult( + Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + return legacyStored.environmentId === environmentId + ? Option.some(legacyStored.snapshot) + : Option.none(); + }), + saveShell: (environmentId, snapshot) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "save-shell"); + const stored = { + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + } as const; + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(StoredShellSnapshot)(stored), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-shell", cause), + }); + }), + loadThread: (environmentId, threadId) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); + if (!file.exists) { + return Option.none(); + } + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-thread", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-thread", cause), + }); + const stored = yield* Effect.fromResult( + Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), + ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); + return stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.thread) + : Option.none(); + }), + saveThread: (environmentId, thread) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread"); + const encoded = yield* Effect.fromResult( + Schema.encodeUnknownResult(StoredThreadSnapshot)({ + schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + threadId: thread.id, + thread, + }), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-thread", cause), + }); + }), + removeThread: (environmentId, threadId) => + Effect.gen(function* () { + const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); + if (file.exists) { + file.delete(); + } + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : shellPersistenceError("remove-thread", cause), + ), + ), + clear: (environmentId) => + Effect.gen(function* () { + const file = yield* shellSnapshotFile(environmentId, "clear-environment"); + if (file.exists) { + yield* Effect.try({ + try: () => file.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); + if (legacyFile.exists) { + yield* Effect.try({ + try: () => legacyFile.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + const threadDirectory = yield* threadSnapshotDirectory( + environmentId, + "clear-environment", + ); + if (threadDirectory.exists) { + yield* Effect.try({ + try: () => threadDirectory.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } + }), + }); + + return Context.make(ConnectionTargetStore, targetStore).pipe( + Context.add(ConnectionRegistrationStore, registrationStore), + Context.add(ConnectionProfileStore, profileStore), + Context.add(ConnectionCredentialStore, credentialStore), + Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(EnvironmentCacheStore, cacheStore), + ); + }), +); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index f06868ed7d9..ec50e4ae9ce 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -2,7 +2,8 @@ import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import type { EnvironmentId } from "@t3tools/contracts"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; +import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; @@ -33,90 +34,85 @@ const connection: SavedRemoteConnection = { bearerToken: "local-bearer", }; -const runWithHttpClient = ( - effect: Effect.Effect, -): Promise => - Effect.runPromise( - effect.pipe( - Effect.provideService(ManagedRelayClient, null as never), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("unexpected HTTP request")), - ), - ), - ); +const testLayer = Layer.mergeAll( + Layer.succeed(ManagedRelayClient, null as never), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HTTP request")), + ), +); describe("liveActivityPreferences", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("pushes disabled Live Activity preferences to relay registrations", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + it.effect("pushes disabled Live Activity preferences to relay registrations", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: "clerk-token", connections: [connection], - }), - ); - - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); - }); + }); - it("pushes enabled Live Activity preferences to relay registrations", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("pushes enabled Live Activity preferences to relay registrations", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: true, clerkToken: "clerk-token", connections: [connection], - }), - ); - - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); - }); + }); - it("keeps local preferences refreshable when signed out", async () => { - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps local preferences refreshable when signed out", () => + Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: false, clerkToken: null, connections: [connection], - }), - ); + }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); - }); + expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); + expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer)), + ); - it("does not try to re-link managed relay connections without bearer credentials", async () => { + it.effect("does not try to re-link managed relay connections without bearer credentials", () => { const managedConnection: SavedRemoteConnection = { ...connection, bearerToken: null, }; - await runWithHttpClient( - setLiveActivityUpdatesEnabled({ + return Effect.gen(function* () { + yield* setLiveActivityUpdatesEnabled({ enabled: true, clerkToken: "clerk-token", connections: [connection, managedConnection], - }), - ); - - expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - connection, - }); + }); + + expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + }); + }).pipe(Effect.provide(testLayer)); }); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 7bf29483f1d..a522129d40d 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,6 +1,6 @@ import * as Effect from "effect/Effect"; import { HttpClient } from "effect/unstable/http"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; import { savePreferencesPatch } from "../../lib/storage"; diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 44ef38df0ef..a4e6fc3d6db 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -1,6 +1,6 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; -import type { MobilePreferences } from "../../lib/storage"; +import type { Preferences } from "../../lib/storage"; export function makeRelayDeviceRegistrationRequest(input: { readonly deviceId: string; @@ -10,7 +10,7 @@ export function makeRelayDeviceRegistrationRequest(input: { readonly pushToken?: string; readonly pushToStartToken?: string; readonly notificationsEnabled: boolean; - readonly preferences: MobilePreferences; + readonly preferences: Preferences; }): RelayDeviceRegistrationRequest { const liveActivitiesEnabled = input.preferences.liveActivitiesEnabled !== false; return { diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 346680df8c0..ebb506e1d67 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -4,17 +4,19 @@ import * as NodeCrypto from "node:crypto"; import { beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import Constants from "expo-constants"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; -import type { ManagedRelayClient } from "@t3tools/client-runtime"; +import { type ManagedRelayClient } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import { verifyDpopProof } from "@t3tools/shared/dpop"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { mobileCryptoLayer } from "../cloud/dpop"; -import { mobileManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { cryptoLayer } from "../cloud/dpop"; +import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { makeRelayDeviceRegistrationRequest } from "./registrationPayload"; import { __resetAgentAwarenessRemoteRegistrationForTest, @@ -33,6 +35,13 @@ const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ getInstances: vi.fn(() => []), })); +const backgroundRuntime = vi.hoisted(() => ({ + pending: [] as Array<{ + readonly operation: unknown; + readonly resolve: (value: unknown) => void; + readonly reject: (error: unknown) => void; + }>, +})); vi.mock("expo-constants", () => ({ default: { @@ -95,17 +104,11 @@ vi.mock("react-native", () => ({ })); vi.mock("../../lib/runtime", () => ({ - mobileRuntime: { - runPromise: (operation: Effect.Effect) => - Effect.runPromise( - operation.pipe( - Effect.provide( - mobileManagedRelayClientLayer("https://relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), - ), - ), - ), - ), + runtime: { + runPromise: (operation: unknown) => + new Promise((resolve, reject) => { + backgroundRuntime.pending.push({ operation, resolve, reject }); + }), }, })); @@ -138,34 +141,38 @@ function savedConnection(): SavedRemoteConnection { }; } -const runRegistrationEffect = (effect: Effect.Effect): Promise => - Effect.runPromise( - effect.pipe( - Effect.provide( - mobileManagedRelayClientLayer("https://relay.example.test").pipe( - Layer.provide(Layer.mergeAll(FetchHttpClient.layer, mobileCryptoLayer)), - ), - ), - ), - ); - -async function waitForFetchCalls( - fetchMock: ReturnType, - count: number, -): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (fetchMock.mock.calls.length >= count) { - return; +const relayTestLayer = managedRelayClientLayer("https://relay.example.test").pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, cryptoLayer)), +); + +const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundOperations")( + function* () { + for (;;) { + yield* Effect.promise(() => Promise.resolve()); + const pending = backgroundRuntime.pending.shift(); + if (!pending) { + return; + } + const exit = yield* Effect.exit( + pending.operation as Effect.Effect, + ); + yield* Effect.sync(() => { + if (Exit.isSuccess(exit)) { + pending.resolve(exit.value); + } else { + pending.reject(Cause.squash(exit.cause)); + } + }); } - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} + }, +); describe("makeRelayDeviceRegistrationRequest", () => { beforeEach(() => { vi.unstubAllGlobals(); vi.stubGlobal("__DEV__", false); secureStore.clear(); + backgroundRuntime.pending.length = 0; Constants.expoConfig!.extra = {}; __resetAgentAwarenessRemoteRegistrationForTest(); widgetMocks.getInstances.mockReset(); @@ -243,7 +250,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); }); - it("registers at most one listener while a Live Activity push token is pending", async () => { + it.effect("registers at most one listener while a Live Activity push token is pending", () => { registerAgentAwarenessConnection(savedConnection()); const addPushTokenListener = vi.fn(); const activity = { @@ -251,56 +258,64 @@ describe("makeRelayDeviceRegistrationRequest", () => { addPushTokenListener, }; - await expect( - runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), - ).resolves.toBe(false); - await expect( - runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), - ).resolves.toBe(false); + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); - expect(activity.getPushToken).toHaveBeenCalledTimes(2); - expect(addPushTokenListener).toHaveBeenCalledTimes(1); + expect(activity.getPushToken).toHaveBeenCalledTimes(2); + expect(addPushTokenListener).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); }); - it("reports Live Activity token registration as skipped when relay auth is unavailable", async () => { - registerAgentAwarenessConnection(savedConnection()); - const activity = { - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - }; + it.effect( + "reports Live Activity token registration as skipped when relay auth is unavailable", + () => { + registerAgentAwarenessConnection(savedConnection()); + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + }; - await expect( - runRegistrationEffect(registerLiveActivityPushToken({ activity: activity as never })), - ).resolves.toBe(false); - }); + return Effect.gen(function* () { + expect(yield* registerLiveActivityPushToken({ activity: activity as never })).toBe(false); + }).pipe(Effect.provide(relayTestLayer)); + }, + ); - it("registers APNS-started Live Activities for relay updates without mutating them locally", async () => { - const activity = { - getPushToken: vi.fn(() => Promise.resolve("activity-token")), - addPushTokenListener: vi.fn(), - start: vi.fn(), - update: vi.fn(), - end: vi.fn(), - }; - widgetMocks.getInstances.mockReturnValue([activity] as never); - setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + it.effect( + "registers APNS-started Live Activities for relay updates without mutating them locally", + () => { + const activity = { + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + start: vi.fn(), + update: vi.fn(), + end: vi.fn(), + }; + widgetMocks.getInstances.mockReturnValue([activity] as never); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); - await runRegistrationEffect(refreshActiveLiveActivityRemoteRegistration()); + return Effect.gen(function* () { + yield* refreshActiveLiveActivityRemoteRegistration(); - expect(activity.getPushToken).toHaveBeenCalled(); - expect(activity.start).not.toHaveBeenCalled(); - expect(activity.update).not.toHaveBeenCalled(); - expect(activity.end).not.toHaveBeenCalled(); - }); + expect(activity.getPushToken).toHaveBeenCalled(); + expect(activity.start).not.toHaveBeenCalled(); + expect(activity.update).not.toHaveBeenCalled(); + expect(activity.end).not.toHaveBeenCalled(); + }).pipe(Effect.provide(relayTestLayer)); + }, + ); - it("refreshes APNs registration for connected environments after settings changes", async () => { + it.effect("refreshes APNs registration for connected environments after settings changes", () => { registerAgentAwarenessConnection(savedConnection()); - await new Promise((resolve) => setTimeout(resolve, 0)); - vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + vi.mocked(Notifications.getDevicePushTokenAsync).mockClear(); - await runRegistrationEffect(refreshAgentAwarenessRegistration()); + yield* refreshAgentAwarenessRegistration(); - expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); }); it.effect("registers the APNs device when cloud auth becomes available", () => { @@ -330,7 +345,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); return Effect.gen(function* () { - yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); + yield* runBackgroundOperations(); expect(fetchMock).toHaveBeenCalledTimes(2); const [request, init] = fetchMock.mock.calls[1] as unknown as [ @@ -357,7 +372,41 @@ describe("makeRelayDeviceRegistrationRequest", () => { nowEpochSeconds: proofIat(dpop), }), ).toMatchObject({ ok: true }); + }).pipe(Effect.provide(relayTestLayer)); + }); + + it.effect("coalesces simultaneous sign-in and environment connection registrations", () => { + const fetchMock = vi.fn((request: RequestInfo | URL) => { + const url = request instanceof Request ? request.url : String(request); + return Promise.resolve( + Response.json( + url.endsWith("/v1/client/dpop-token") + ? { + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + } + : { ok: true }, + ), + ); }); + vi.stubGlobal("fetch", fetchMock); + Constants.expoConfig!.extra = { + relay: { + url: "https://relay.example.test/", + }, + }; + + vi.mocked(Notifications.getPermissionsAsync).mockClear(); + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + registerAgentAwarenessConnection(savedConnection()); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(Notifications.getPermissionsAsync).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); }); it("only registers again when the authenticated identity changes", () => { @@ -367,7 +416,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(shouldRegisterAgentAwarenessDeviceForProvider("user-a", undefined)).toBe(true); }); - it("registers rotated APNs tokens without rereading the native token", async () => { + it.effect("registers rotated APNs tokens without rereading the native token", () => { const fetchMock = vi.fn((request: RequestInfo | URL) => { const url = request instanceof Request ? request.url : String(request); return Promise.resolve( @@ -398,9 +447,10 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(tokenListener).toBeDefined(); tokenListener?.({ type: "ios", data: "rotated-apns-token" } as never); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + return Effect.gen(function* () { + yield* runBackgroundOperations(); + expect(Notifications.getDevicePushTokenAsync).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(relayTestLayer)); }); it.effect( @@ -432,13 +482,13 @@ describe("makeRelayDeviceRegistrationRequest", () => { registerAgentAwarenessConnection(savedConnection()); setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); return Effect.gen(function* () { - yield* Effect.promise(() => waitForFetchCalls(fetchMock, 2)); + yield* runBackgroundOperations(); fetchMock.mockClear(); unregisterAgentAwarenessConnection(savedConnection().environmentId); expect(fetchMock).not.toHaveBeenCalled(); - }); + }).pipe(Effect.provide(relayTestLayer)); }, ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 3e49ec1e257..be91420bef5 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -8,10 +8,11 @@ import { type RelayDeviceRegistrationRequest, type RelayLiveActivityRegistrationRequest, } from "@t3tools/contracts/relay"; -import { ManagedRelayClient } from "@t3tools/client-runtime"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { ManagedRelayClient } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { mobileRuntime } from "../../lib/runtime"; +import { runtime } from "../../lib/runtime"; import { loadAgentAwarenessDeviceId, loadOrCreateAgentAwarenessDeviceId, @@ -29,6 +30,20 @@ let pushTokenSubscription: { remove: () => void } | null = null; let activeLiveActivityRegistrationRetry: ReturnType | null = null; let relayTokenProvider: (() => Promise) | null = null; let relayTokenProviderIdentity: string | null = null; +let deviceRegistrationGeneration = 0; +let activeDeviceRegistration: { + readonly input: DeviceRegistrationInput; + readonly operation: Promise; +} | null = null; +let pendingDeviceRegistration: { + readonly input: DeviceRegistrationInput; + readonly context: string; +} | null = null; + +interface DeviceRegistrationInput { + readonly pushToStartToken?: string; + readonly observedPushToken?: string; +} export function normalizeAgentAwarenessRelayBaseUrl( value: string | null | undefined, @@ -68,6 +83,11 @@ export function setAgentAwarenessRelayTokenProvider( const isExistingIdentity = provider !== null && !shouldRegisterAgentAwarenessDeviceForProvider(relayTokenProviderIdentity, identity); + if (!isExistingIdentity) { + deviceRegistrationGeneration++; + activeDeviceRegistration = null; + pendingDeviceRegistration = null; + } relayTokenProvider = provider; relayTokenProviderIdentity = provider ? (identity ?? null) : null; if (!provider) { @@ -90,7 +110,7 @@ export function setAgentAwarenessRelayTokenProvider( if (isExistingIdentity) { return; } - runRegistrationInBackground(registerDevice(), "device registration after cloud sign-in failed"); + enqueueDeviceRegistration({}, "device registration after cloud sign-in failed"); } function iosMajorVersion(): number { @@ -149,20 +169,41 @@ const relayToken = Effect.gen(function* () { function registerDeviceWithRelay( body: RelayDeviceRegistrationRequest, + expectedGeneration: number, ): Effect.Effect { return Effect.gen(function* () { + if (expectedGeneration !== deviceRegistrationGeneration) { + logRegistrationDebug("device registration cancelled before relay request", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } if (!readRelayConfig()) return; const token = yield* relayToken; + if (expectedGeneration !== deviceRegistrationGeneration) { + logRegistrationDebug("device registration cancelled after auth lookup", { + expectedGeneration, + currentGeneration: deviceRegistrationGeneration, + }); + return; + } if (!token) { logRegistrationDebug("relay device registration skipped; user is not signed in"); return; } const client = yield* ManagedRelayClient; + logRegistrationDebug("relay device registration request started", { + expectedGeneration, + }); yield* client.registerDevice({ clerkToken: token, payload: body, }); + logRegistrationDebug("relay device registration request completed", { + expectedGeneration, + }); }); } @@ -213,10 +254,11 @@ function logRegistrationError(context: string, error: unknown): void { if (!__DEV__) { return; } - console.warn( - `[agent-awareness] ${context}`, - error instanceof Error ? error.message : String(error), - ); + console.warn(`[agent-awareness] ${context}`, { + message: error instanceof Error ? error.message : String(error), + traceId: findErrorTraceId(error), + error, + }); } function logRegistrationDebug(context: string, details?: unknown): void { @@ -230,20 +272,99 @@ function runRegistrationInBackground( operation: Effect.Effect, context: string, ): void { - void mobileRuntime.runPromise(operation).catch((error: unknown) => { + void runtime.runPromise(operation).catch((error: unknown) => { logRegistrationError(context, error); }); } -function registerDevice(input?: { - readonly pushToStartToken?: string; - readonly observedPushToken?: string; -}): Effect.Effect { +function mergeDeviceRegistrationInput( + current: DeviceRegistrationInput, + next: DeviceRegistrationInput, +): DeviceRegistrationInput { + return { + ...((next.pushToStartToken ?? current.pushToStartToken) + ? { pushToStartToken: next.pushToStartToken ?? current.pushToStartToken } + : {}), + ...((next.observedPushToken ?? current.observedPushToken) + ? { observedPushToken: next.observedPushToken ?? current.observedPushToken } + : {}), + }; +} + +function registrationAddsInformation( + current: DeviceRegistrationInput, + next: DeviceRegistrationInput, +): boolean { + return ( + (next.pushToStartToken !== undefined && next.pushToStartToken !== current.pushToStartToken) || + (next.observedPushToken !== undefined && next.observedPushToken !== current.observedPushToken) + ); +} + +function startPendingDeviceRegistration(): void { + if (activeDeviceRegistration || !pendingDeviceRegistration) { + return; + } + + const next = pendingDeviceRegistration; + pendingDeviceRegistration = null; + const generation = deviceRegistrationGeneration; + logRegistrationDebug("device registration started", { + generation, + hasObservedPushToken: next.input.observedPushToken !== undefined, + hasPushToStartToken: next.input.pushToStartToken !== undefined, + }); + const operation = runtime + .runPromise(registerDevice(next.input, generation)) + .catch((error: unknown) => { + logRegistrationError(next.context, error); + }) + .finally(() => { + logRegistrationDebug("device registration finished", { generation }); + if (activeDeviceRegistration?.operation === operation) { + activeDeviceRegistration = null; + } + startPendingDeviceRegistration(); + }); + activeDeviceRegistration = { input: next.input, operation }; +} + +function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: string): void { + if ( + activeDeviceRegistration && + !registrationAddsInformation(activeDeviceRegistration.input, input) + ) { + logRegistrationDebug("device registration coalesced with active request", { + generation: deviceRegistrationGeneration, + }); + return; + } + + logRegistrationDebug("device registration enqueued", { + generation: deviceRegistrationGeneration, + hasActiveRegistration: activeDeviceRegistration !== null, + hasPendingRegistration: pendingDeviceRegistration !== null, + }); + pendingDeviceRegistration = pendingDeviceRegistration + ? { + input: mergeDeviceRegistrationInput(pendingDeviceRegistration.input, input), + context, + } + : { input, context }; + startPendingDeviceRegistration(); +} + +function registerDevice( + input: DeviceRegistrationInput = {}, + expectedGeneration = deviceRegistrationGeneration, +): Effect.Effect { return Effect.gen(function* () { if (!canRegisterRemoteLiveActivities()) { + logRegistrationDebug("device registration skipped; platform does not support it"); return; } + logRegistrationDebug("device registration loading local state", { expectedGeneration }); const [deviceId, preferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), @@ -255,6 +376,10 @@ function registerDevice(input?: { }), ]); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); + logRegistrationDebug("device registration local state ready", { + expectedGeneration, + notificationsEnabled: pushTokenRegistration.notificationsEnabled, + }); yield* registerDeviceWithRelay( makeRelayDeviceRegistrationRequest({ deviceId, @@ -266,6 +391,7 @@ function registerDevice(input?: { notificationsEnabled: pushTokenRegistration.notificationsEnabled, preferences, }), + expectedGeneration, ); }); } @@ -277,10 +403,7 @@ function registerDeviceForCurrentUser( } function registerPushToStartTokenForCurrentUser(pushToStartToken: string): void { - runRegistrationInBackground( - registerDeviceForCurrentUser(pushToStartToken), - "push-to-start token registration failed", - ); + enqueueDeviceRegistration({ pushToStartToken }, "push-to-start token registration failed"); } function ensurePushToStartListener(): void { @@ -303,8 +426,8 @@ function ensurePushTokenListener(): void { pushTokenSubscription = Notifications.addPushTokenListener((token) => { if (token.type === "ios" && typeof token.data === "string" && token.data.trim().length > 0) { - runRegistrationInBackground( - registerDevice({ observedPushToken: token.data.trim() }), + enqueueDeviceRegistration( + { observedPushToken: token.data.trim() }, "native APNs token rotation registration failed", ); } @@ -319,7 +442,7 @@ export function registerAgentAwarenessConnection(connection: SavedRemoteConnecti environmentConnections.set(connection.environmentId, connection); ensurePushToStartListener(); ensurePushTokenListener(); - runRegistrationInBackground(registerDevice(), "device registration failed"); + enqueueDeviceRegistration({}, "device registration failed"); runRegistrationInBackground( refreshActiveLiveActivityRemoteRegistration(), "active live activity registration after environment connection failed", @@ -372,6 +495,9 @@ export function __resetAgentAwarenessRemoteRegistrationForTest(): void { } relayTokenProvider = null; relayTokenProviderIdentity = null; + deviceRegistrationGeneration++; + activeDeviceRegistration = null; + pendingDeviceRegistration = null; } export function unregisterAgentAwarenessDeviceForCurrentUser( diff --git a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx new file mode 100644 index 00000000000..8bd51b8518d --- /dev/null +++ b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx @@ -0,0 +1,44 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react"; + +interface ClerkSettingsSheetDetentValue { + collapse: () => void; + expand: () => void; + isExpanded: boolean; +} + +const ClerkSettingsSheetDetentContext = createContext(null); + +interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { + initiallyExpanded: boolean; +} + +export function ClerkSettingsSheetDetentProvider({ + children, + initiallyExpanded, +}: ClerkSettingsSheetDetentProviderProps) { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + const collapse = useCallback(() => setIsExpanded(false), []); + const expand = useCallback(() => setIsExpanded(true), []); + const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); + + return ( + {children} + ); +} + +export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { + const value = useContext(ClerkSettingsSheetDetentContext); + if (!value) { + throw new Error( + "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", + ); + } + return value; +} diff --git a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx index 5fc3b96fdc8..bf400639b19 100644 --- a/apps/mobile/src/features/cloud/CloudAuthProvider.tsx +++ b/apps/mobile/src/features/cloud/CloudAuthProvider.tsx @@ -1,9 +1,15 @@ import { ClerkProvider, useAuth } from "@clerk/expo"; import { tokenCache } from "@clerk/expo/token-cache"; -import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; +import { + createManagedRelaySession, + ManagedRelayClient, + setManagedRelaySession, +} from "@t3tools/client-runtime/relay"; +import * as Effect from "effect/Effect"; import { type ReactNode, useEffect, useRef } from "react"; -import { mobileRuntime } from "../../lib/runtime"; +import { useEnvironmentConnectionActions } from "../../state/environments"; +import { runtime } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; import { setAgentAwarenessRelayTokenProvider, @@ -11,47 +17,100 @@ import { } from "../agent-awareness/remoteRegistration"; import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; +function resetManagedRelayTokenCache(): Promise { + return runtime.runPromise( + ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ); +} + function CloudAuthBridge(props: { readonly children: ReactNode }) { const { getToken, isLoaded, isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); + const { removeRelayEnvironments } = useEnvironmentConnectionActions(); const previousTokenProviderRef = useRef<{ readonly userId: string; readonly provider: () => Promise; } | null>(null); + const observedAccountRef = useRef(undefined); + const accountTransitionRef = useRef(Promise.resolve()); useEffect(() => { + let cancelled = false; if (!isLoaded) { return; } + + const previousObservedAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + + const queueAccountCleanup = ( + previous: { + readonly userId: string; + readonly provider: () => Promise; + } | null, + ) => { + accountTransitionRef.current = accountTransitionRef.current.then(async () => { + const cleanup = [ + resetManagedRelayTokenCache(), + removeRelayEnvironments(), + ...(previous + ? [runtime.runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider))] + : []), + ]; + const results = await Promise.allSettled(cleanup); + for (const result of results) { + if (result.status === "rejected") { + console.warn("[t3-cloud] cloud account cleanup failed", result.reason); + } + } + }); + return accountTransitionRef.current; + }; + if (!isSignedIn || !userId) { const previous = previousTokenProviderRef.current; previousTokenProviderRef.current = null; - if (previous) { - void mobileRuntime - .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) - .catch(() => undefined); - } setAgentAwarenessRelayTokenProvider(null); setManagedRelaySession(appAtomRegistry, null); + if (previousObservedAccount !== null) { + void queueAccountCleanup(previous); + } return; } const previous = previousTokenProviderRef.current; - if (previous && previous.userId !== userId) { - void mobileRuntime - .runPromise(unregisterAgentAwarenessDeviceForCurrentUser(previous.provider)) - .catch(() => undefined); - } const tokenProvider = () => getToken(resolveRelayClerkTokenOptions()); - previousTokenProviderRef.current = { userId, provider: tokenProvider }; - setAgentAwarenessRelayTokenProvider(tokenProvider, userId); - setManagedRelaySession( - appAtomRegistry, - createManagedRelaySession({ - accountId: userId, - readClerkToken: tokenProvider, - }), - ); - }, [getToken, isLoaded, isSignedIn, userId]); + const activateSession = () => { + if (cancelled) { + return; + } + previousTokenProviderRef.current = { userId, provider: tokenProvider }; + setAgentAwarenessRelayTokenProvider(tokenProvider, userId); + setManagedRelaySession( + appAtomRegistry, + createManagedRelaySession({ + accountId: userId, + readClerkToken: tokenProvider, + }), + ); + }; + if ( + previousObservedAccount !== undefined && + previousObservedAccount !== null && + previousObservedAccount !== userId + ) { + previousTokenProviderRef.current = null; + setAgentAwarenessRelayTokenProvider(null); + setManagedRelaySession(appAtomRegistry, null); + void queueAccountCleanup(previous).then(activateSession); + } else { + void accountTransitionRef.current.then(activateSession); + } + + return () => { + cancelled = true; + }; + }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); useEffect( () => () => { diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts new file mode 100644 index 00000000000..840a3db5568 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -0,0 +1,18 @@ +export function isCloudDebugEnabled(): boolean { + return ( + (typeof __DEV__ !== "undefined" && __DEV__) || + (typeof globalThis !== "undefined" && + (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) + ); +} + +export function cloudDebugLog(event: string, data?: Record): void { + if (!isCloudDebugEnabled()) { + return; + } + if (data) { + console.log(`[t3-cloud] ${event}`, data); + } else { + console.log(`[t3-cloud] ${event}`); + } +} diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts new file mode 100644 index 00000000000..8143eda09a1 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts @@ -0,0 +1,86 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; + +import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation"; + +function relayStatus( + status: RelayEnvironmentStatusResponse["status"], + error?: string, +): RelayEnvironmentStatusResponse { + return { + environmentId: EnvironmentId.make("environment-cloud"), + endpoint: { + httpBaseUrl: "https://cloud.example.test/", + wsBaseUrl: "wss://cloud.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status, + checkedAt: "2026-06-05T16:49:11.000Z", + ...(error ? { error } : {}), + }; +} + +describe("available cloud environment presentation", () => { + it("presents an online unsaved environment as available, not connected", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: relayStatus("online"), + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Relay online", + }); + }); + + it("keeps relay status checks distinct from connection attempts", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: true, + status: null, + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Checking relay status...", + }); + }); + + it("surfaces an offline relay as an error", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: relayStatus("offline", "Tunnel is unavailable."), + statusError: null, + statusErrorTraceId: null, + }), + ).toEqual({ + connectionError: "Tunnel is unavailable.", + connectionErrorTraceId: null, + connectionState: "error", + statusText: "Tunnel is unavailable.", + }); + }); + + it("preserves trace metadata for relay request failures", () => { + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status: null, + statusError: "Could not get relay environment status.", + statusErrorTraceId: "trace-status", + }), + ).toMatchObject({ + connectionError: "Could not get relay environment status.", + connectionErrorTraceId: "trace-status", + }); + }); +}); diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts new file mode 100644 index 00000000000..0346a1be9d7 --- /dev/null +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts @@ -0,0 +1,53 @@ +import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + +export interface AvailableCloudEnvironmentPresentation { + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly connectionState: EnvironmentConnectionPhase; + readonly statusText: string; +} + +export function availableCloudEnvironmentPresentation(input: { + readonly isStatusPending: boolean; + readonly status: RelayEnvironmentStatusResponse | null; + readonly statusError: string | null; + readonly statusErrorTraceId: string | null; +}): AvailableCloudEnvironmentPresentation { + if (input.status?.status === "online") { + return { + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: "Available · Relay online", + }; + } + + if (input.status?.status === "offline") { + const connectionError = input.status.error ?? "Relay is offline."; + return { + connectionError, + connectionErrorTraceId: null, + connectionState: "error", + statusText: connectionError, + }; + } + + if (input.statusError) { + return { + connectionError: input.statusError, + connectionErrorTraceId: input.statusErrorTraceId, + connectionState: "error", + statusText: input.statusError, + }; + } + + return { + connectionError: null, + connectionErrorTraceId: null, + connectionState: "available", + statusText: input.isStatusPending + ? "Available · Checking relay status..." + : "Available · Relay status unknown", + }; +} diff --git a/apps/mobile/src/features/cloud/dpop.test.ts b/apps/mobile/src/features/cloud/dpop.test.ts index 8eda21b96ce..8945d148ee9 100644 --- a/apps/mobile/src/features/cloud/dpop.test.ts +++ b/apps/mobile/src/features/cloud/dpop.test.ts @@ -12,7 +12,7 @@ import { createDpopProof, generateDpopProofKeyPair, loadOrCreateDpopProofKeyPair, - mobileCryptoLayer, + cryptoLayer, } from "./dpop"; vi.mock("expo-crypto", () => ({ @@ -75,7 +75,7 @@ describe("mobile DPoP", () => { expect(Buffer.from(digest).toString("hex")).toBe( NodeCrypto.createHash("sha256").update("typed-array").digest("hex"), ); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("persists and reuses the installation proof key", () => @@ -86,7 +86,7 @@ describe("mobile DPoP", () => { expect(second.thumbprint).toBe(first.thumbprint); expect(second.privateJwk).toEqual(first.privateJwk); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("rejects malformed persisted proof keys", () => @@ -96,7 +96,7 @@ describe("mobile DPoP", () => { const error = yield* loadOrCreateDpopProofKeyPair().pipe(Effect.flip); expect(error.message).toBe("Stored DPoP proof key is invalid."); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("signs connect and bootstrap proofs with the same ephemeral proof key", () => @@ -135,7 +135,7 @@ describe("mobile DPoP", () => { nowEpochSeconds: proofIat(bootstrap.proof), }), ).toMatchObject({ ok: true, thumbprint: proofKey.thumbprint }); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); it.effect("signs DPoP proofs with RFC 9449 htu normalization", () => @@ -161,6 +161,6 @@ describe("mobile DPoP", () => { nowEpochSeconds: proofIat(proof.proof), }), ).toMatchObject({ ok: true }); - }).pipe(Effect.provide(mobileCryptoLayer)), + }).pipe(Effect.provide(cryptoLayer)), ); }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index 0a3d7c2a5a7..0bd4b7ff1bd 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -70,7 +70,7 @@ function toExpoDigestAlgorithm( } } -export const mobileCryptoLayer = Layer.succeed( +export const cryptoLayer = Layer.succeed( Crypto.Crypto, Crypto.make({ randomBytes: ExpoCrypto.getRandomBytes, diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 36544cf46cc..aa1071fd3c2 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -8,8 +8,8 @@ import { managedRelayClientLayer, ManagedRelayClient, ManagedRelayDpopSigner, - remoteHttpClientLayer, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { @@ -55,6 +55,8 @@ const savedConnection = { bearerToken: "local-bearer", }; +const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test"; + const createProofMock = vi.fn( (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => Effect.succeed(`dpop:${input.method}:${input.url}`), @@ -352,7 +354,7 @@ describe("mobile cloud link environment client", () => { }); vi.stubGlobal("fetch", fetchMock); - yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" })); + yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken })); expect( fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), @@ -425,9 +427,11 @@ describe("mobile cloud link environment client", () => { yield* withCloudServices( Effect.gen(function* () { - const records = yield* listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }); + const records = yield* listCloudEnvironmentsWithStatus({ + clerkToken: stableClerkToken, + }); yield* connectCloudEnvironment({ - clerkToken: "clerk-token", + clerkToken: stableClerkToken, environment: records[0]!.environment, }); }), @@ -658,6 +662,7 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkError", message: "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + traceId: "trace-test", }); expect(fetchMock).toHaveBeenCalledTimes(3); }), @@ -1003,6 +1008,7 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkError", message: "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + traceId: "trace-connect", }); }), ); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index bca1ac21bc7..680e6e80cfa 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -16,22 +16,23 @@ import { type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, - RelayProtectedError, type RelayDpopAccessTokenScope, type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; +import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import { - exchangeRemoteDpopAccessToken, - fetchRemoteEnvironmentDescriptor, - makeEnvironmentHttpApiClient, ManagedRelayClient, + type ManagedRelayClientError, ManagedRelayDpopSigner, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { mobileAuthClientMetadata } from "../../lib/authClientMetadata"; +import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; import { resolveCloudPublicConfig } from "./publicConfig"; @@ -56,6 +57,7 @@ function readRelayUrl(): string | null { export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ readonly message: string; readonly cause?: unknown; + readonly traceId?: string; }> {} export interface CloudEnvironmentRecordWithStatus { @@ -64,7 +66,6 @@ export interface CloudEnvironmentRecordWithStatus { readonly statusError: string | null; } -const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -82,11 +83,13 @@ const MANAGED_ENDPOINT_PROVIDER_KIND = function cloudEnvironmentLinkError(message: string) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); + const traceId = findErrorTraceId(cause); return new CloudEnvironmentLinkError({ message: environmentError ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` : withDevCause(message, cause), cause, + ...(traceId === null ? {} : { traceId }), }); }; } @@ -148,31 +151,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayAgentActivityPublishProofInvalidError": return `Relay rejected the agent activity publish proof (${error.reason}).`; case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + return `Relay encountered an internal error (${error.reason}).`; } } function decodedRelayClientError(message: string) { - return (cause: unknown) => { - const relayError = findRelayProtectedError(cause); + return (cause: ManagedRelayClientError) => { + const relayError = cause.relayError; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(cause.traceId ? { traceId: cause.traceId } : {}), }); }; } -function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { - if (isRelayProtectedError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findRelayProtectedError(cause.cause) : null; -} - function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { if (isEnvironmentCloudApiError(cause)) { return cause; @@ -462,23 +456,26 @@ export function listCloudEnvironmentsWithStatus(input: { }); } -function connectRelayManagedEnvironment(input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly expectedEnvironment?: RelayClientEnvironmentRecord; -}): Effect.Effect< - SavedRemoteConnection, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner -> { - return Effect.gen(function* () { - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelayClient; - - const deviceId = yield* Effect.tryPromise({ +const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( + function* () { + return yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: cloudEnvironmentLinkError("Could not load the mobile device id."), }); + }, +); + +const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")( + function* (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly expectedEnvironment?: RelayClientEnvironmentRecord; + }) { + yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId }); + const relayUrl = yield* requireRelayUrl(); + const relayClient = yield* ManagedRelayClient; + + const deviceId = yield* loadAgentAwarenessDeviceId(); const connect = yield* relayClient .connectEnvironment({ clerkToken: input.clerkToken, @@ -528,7 +525,7 @@ function connectRelayManagedEnvironment(input: { httpBaseUrl: connect.endpoint.httpBaseUrl, credential: connect.credential, dpopProof: bootstrapDpop, - clientMetadata: mobileAuthClientMetadata(), + clientMetadata: authClientMetadata(), }).pipe( Effect.mapError( cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), @@ -548,9 +545,9 @@ function connectRelayManagedEnvironment(input: { authenticationMethod: "dpop", dpopAccessToken: bootstrap.access_token, relayManaged: true, - }; - }); -} + } satisfies SavedRemoteConnection; + }, +); export function connectCloudEnvironment(input: { readonly clerkToken: string; diff --git a/apps/mobile/src/features/cloud/managedRelayLayer.ts b/apps/mobile/src/features/cloud/managedRelayLayer.ts index 0de43d049c5..6678d13047e 100644 --- a/apps/mobile/src/features/cloud/managedRelayLayer.ts +++ b/apps/mobile/src/features/cloud/managedRelayLayer.ts @@ -1,42 +1,46 @@ import { - managedRelayClientLayer, + managedRelayClientLayer as makeManagedRelayClientLayer, ManagedRelayDpopSigner, ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { createDpopProof, loadOrCreateDpopProofKeyPair } from "./dpop"; +import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; -const mobileRelayDpopSignerLayer = Layer.effect( +const relayDpopSignerLayer = Layer.effect( ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; + const loadProofKey = yield* Effect.cached( + loadOrCreateDpopProofKeyPair().pipe(Effect.provideService(Crypto.Crypto, crypto)), + ); return ManagedRelayDpopSigner.of({ - thumbprint: Effect.suspend(() => - loadOrCreateDpopProofKeyPair().pipe( - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), - ), + thumbprint: loadProofKey.pipe( + Effect.map((proofKey) => proofKey.thumbprint), + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.withSpan("mobile.managedRelayDpopSigner.loadThumbprint"), ), - createProof: (input) => - Effect.gen(function* () { - const proofKey = yield* loadOrCreateDpopProofKeyPair().pipe( - Effect.provideService(Crypto.Crypto, crypto), - ); + createProof: Effect.fn("mobile.managedRelayDpopSigner.createProof")( + function* (input) { + const proofKey = yield* loadProofKey; return yield* createDpopProof({ ...input, proofKey }).pipe( Effect.provideService(Crypto.Crypto, crypto), Effect.map((proof) => proof.proof), ); - }).pipe(Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause }))), + }, + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + ), }); }), ); -export const mobileManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayMobileClientId }).pipe( - Layer.provideMerge(mobileRelayDpopSignerLayer), - ); +export const managedRelayClientLayer = (relayUrl: string) => + makeManagedRelayClientLayer({ + relayUrl, + clientId: RelayMobileClientId, + accessTokenStore: managedRelayAccessTokenStore, + }).pipe(Layer.provideMerge(relayDpopSignerLayer)); diff --git a/apps/mobile/src/features/cloud/managedRelayState.ts b/apps/mobile/src/features/cloud/managedRelayState.ts index 3394a519fd6..eec1e3410e6 100644 --- a/apps/mobile/src/features/cloud/managedRelayState.ts +++ b/apps/mobile/src/features/cloud/managedRelayState.ts @@ -3,20 +3,24 @@ import { createManagedRelayQueryManager, managedRelaySessionAtom, readManagedRelaySnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import type { RelayClientEnvironmentRecord, RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; -import { mobileRuntimeContextLayer } from "../../lib/runtime"; +import { runtimeContextLayer } from "../../lib/runtime"; import { appAtomRegistry } from "../../state/atom-registry"; +import { cloudDebugLog } from "./cloudDebugLog"; -const managedRelayAtomRuntime = Atom.runtime(mobileRuntimeContextLayer); +const managedRelayAtomRuntime = Atom.runtime(runtimeContextLayer); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime, { + onQueryEvent: (event) => + cloudDebugLog(`query:${event.operation}:${event.stage}:${event.phase}`, { ...event }), +}); const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), @@ -33,6 +37,15 @@ export function useManagedRelayEnvironments() { ? managedRelayQueryManager.environmentsAtom(accountId) : EMPTY_ENVIRONMENTS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); @@ -40,7 +53,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -53,6 +66,16 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron ? managedRelayQueryManager.environmentStatusAtom({ accountId, environment }) : EMPTY_ENVIRONMENT_STATUS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment status failed", { + environmentId: environment.environmentId, + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [environment.environmentId, snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironmentStatus(appAtomRegistry, { @@ -63,7 +86,7 @@ export function useManagedRelayEnvironmentStatus(environment: RelayClientEnviron }, [accountId, environment]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts new file mode 100644 index 00000000000..616fc1add7c --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +const secureStore = vi.hoisted(() => new Map()); + +vi.mock("expo-secure-store", () => ({ + getItemAsync: vi.fn((key: string) => Promise.resolve(secureStore.get(key) ?? null)), + setItemAsync: vi.fn((key: string, value: string) => { + secureStore.set(key, value); + return Promise.resolve(); + }), + deleteItemAsync: vi.fn((key: string) => { + secureStore.delete(key); + return Promise.resolve(); + }), +})); + +import { managedRelayAccessTokenStore } from "./managedRelayTokenStore"; + +it.effect("round-trips and clears persisted managed relay access tokens", () => + Effect.gen(function* () { + secureStore.clear(); + const entries = [ + { + accountId: "user-1", + clientId: "t3-mobile", + relayUrl: "https://relay.example.test", + thumbprint: "thumbprint", + scopes: ["environment:connect"], + accessToken: "access-token", + expiresAtMillis: 1_800_000, + }, + ] as const; + + yield* managedRelayAccessTokenStore.save(entries); + expect(yield* managedRelayAccessTokenStore.load).toEqual(entries); + + yield* managedRelayAccessTokenStore.clear; + expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + }), +); + +it.effect("falls back to an empty cache when persisted data is invalid", () => + Effect.gen(function* () { + secureStore.clear(); + secureStore.set("t3code.cloud.relay-access-tokens", "not-json"); + + expect(yield* managedRelayAccessTokenStore.load).toEqual([]); + }), +); diff --git a/apps/mobile/src/features/cloud/managedRelayTokenStore.ts b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts new file mode 100644 index 00000000000..54153a426a1 --- /dev/null +++ b/apps/mobile/src/features/cloud/managedRelayTokenStore.ts @@ -0,0 +1,107 @@ +import { + type ManagedRelayAccessTokenCacheEntry, + type ManagedRelayAccessTokenStore, +} from "@t3tools/client-runtime/relay"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +const MANAGED_RELAY_TOKEN_CACHE_KEY = "t3code.cloud.relay-access-tokens"; +const MANAGED_RELAY_TOKEN_CACHE_VERSION = 1; + +const ManagedRelayAccessTokenCacheEntrySchema = Schema.Struct({ + accountId: Schema.String, + clientId: Schema.Literals(["t3-mobile", "t3-web"]), + relayUrl: Schema.String, + thumbprint: Schema.String, + scopes: Schema.Array( + Schema.Literals(["environment:connect", "environment:status", "mobile:registration"]), + ), + accessToken: Schema.String, + expiresAtMillis: Schema.Number, +}); + +const ManagedRelayAccessTokenCacheSchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(MANAGED_RELAY_TOKEN_CACHE_VERSION), + entries: Schema.Array(ManagedRelayAccessTokenCacheEntrySchema), + }), +); + +const decodeManagedRelayAccessTokenCache = Schema.decodeUnknownEffect( + ManagedRelayAccessTokenCacheSchema, +); +const encodeManagedRelayAccessTokenCache = Schema.encodeEffect(ManagedRelayAccessTokenCacheSchema); + +export class ManagedRelayTokenStoreError extends Data.TaggedError("ManagedRelayTokenStoreError")<{ + readonly message: string; + readonly cause: unknown; +}> {} + +const storeError = + (message: string) => + (cause: unknown): ManagedRelayTokenStoreError => + new ManagedRelayTokenStoreError({ message, cause }); + +function logStoreFailure(operation: string) { + return (error: ManagedRelayTokenStoreError) => + Effect.logWarning(`Managed relay token store ${operation} failed.`).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + message: error.message, + }), + ); +} + +const loadManagedRelayAccessTokens = Effect.tryPromise({ + try: () => SecureStore.getItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), + catch: storeError("Could not read persisted relay access tokens."), +}).pipe( + Effect.flatMap((encoded) => + encoded === null + ? Effect.succeed>([]) + : decodeManagedRelayAccessTokenCache(encoded).pipe( + Effect.map((cache) => cache.entries), + Effect.mapError(storeError("Persisted relay access tokens are invalid.")), + ), + ), +); + +const saveManagedRelayAccessTokens = (entries: ReadonlyArray) => + encodeManagedRelayAccessTokenCache({ + version: MANAGED_RELAY_TOKEN_CACHE_VERSION, + entries, + }).pipe( + Effect.mapError(storeError("Could not encode relay access tokens.")), + Effect.flatMap((encoded) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY, encoded), + catch: storeError("Could not persist relay access tokens."), + }), + ), + ); + +const clearManagedRelayAccessTokens = Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(MANAGED_RELAY_TOKEN_CACHE_KEY), + catch: storeError("Could not clear persisted relay access tokens."), +}); + +export const managedRelayAccessTokenStore: ManagedRelayAccessTokenStore = { + load: loadManagedRelayAccessTokens.pipe( + Effect.tapError(logStoreFailure("load")), + Effect.orElseSucceed(() => []), + Effect.withSpan("mobile.managedRelayTokenStore.load"), + ), + save: Effect.fn("mobile.managedRelayTokenStore.save")((entries) => + saveManagedRelayAccessTokens(entries).pipe( + Effect.tapError(logStoreFailure("save")), + Effect.ignore, + ), + ), + clear: clearManagedRelayAccessTokens.pipe( + Effect.tapError(logStoreFailure("clear")), + Effect.ignore, + Effect.withSpan("mobile.managedRelayTokenStore.clear"), + ), +}; diff --git a/apps/mobile/src/features/cloud/publicConfig.test.ts b/apps/mobile/src/features/cloud/publicConfig.test.ts index d5094d71b8b..0307fcdab30 100644 --- a/apps/mobile/src/features/cloud/publicConfig.test.ts +++ b/apps/mobile/src/features/cloud/publicConfig.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; +import { hasTracingPublicConfig, resolveCloudPublicConfig } from "./publicConfig"; vi.mock("expo-constants", () => ({ default: { @@ -94,9 +94,9 @@ describe("resolveCloudPublicConfig", () => { }); it("keeps tracing disabled unless every public tracing value is configured", () => { - expect(hasMobileTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); + expect(hasTracingPublicConfig(resolveCloudPublicConfig({}))).toBe(false); expect( - hasMobileTracingPublicConfig( + hasTracingPublicConfig( resolveCloudPublicConfig({ observability: { tracesUrl: "https://api.axiom.co/v1/traces", @@ -106,7 +106,7 @@ describe("resolveCloudPublicConfig", () => { ), ).toBe(false); expect( - hasMobileTracingPublicConfig( + hasTracingPublicConfig( resolveCloudPublicConfig({ observability: { tracesUrl: "https://api.axiom.co/v1/traces", diff --git a/apps/mobile/src/features/cloud/publicConfig.ts b/apps/mobile/src/features/cloud/publicConfig.ts index 7a8822eb9db..2d304da7c02 100644 --- a/apps/mobile/src/features/cloud/publicConfig.ts +++ b/apps/mobile/src/features/cloud/publicConfig.ts @@ -70,13 +70,13 @@ type Configured = { readonly [Key in keyof T]: NonNullable; }; -type MobileTracingPublicConfig = Omit & { +type TracingPublicConfig = Omit & { readonly observability: Configured; }; -export function hasMobileTracingPublicConfig( +export function hasTracingPublicConfig( config: CloudPublicConfig = resolveCloudPublicConfig(), -): config is MobileTracingPublicConfig { +): config is TracingPublicConfig { return Boolean( config.observability.tracesUrl && config.observability.tracesDataset && diff --git a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts deleted file mode 100644 index 3356642776a..00000000000 --- a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { getClerkInstance } from "@clerk/expo"; -import { tokenCache } from "@clerk/expo/token-cache"; -import * as Data from "effect/Data"; -import { useCallback, useRef } from "react"; -import type { TurboModule } from "react-native"; -import { TurboModuleRegistry } from "react-native"; - -const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; - -interface NativeClerkModule extends TurboModule { - readonly getClientToken?: () => Promise; - readonly presentAuth?: (options: { - readonly dismissable: boolean; - readonly mode: "signInOrUp"; - }) => Promise; -} - -interface NativeAuthResult { - readonly cancelled?: boolean; - readonly session?: { - readonly id?: string; - }; - readonly sessionId?: string; -} - -interface ClerkWithNativeSync { - readonly __internal_reloadInitialResources?: () => Promise; - readonly setActive?: (params: { readonly session: string }) => Promise; -} - -const NativeClerk = TurboModuleRegistry.get("ClerkExpo"); - -class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -async function syncNativeSession(sessionId: string): Promise { - const getClientToken = NativeClerk?.getClientToken; - let nativeClientToken: string | null = null; - if (getClientToken) { - try { - nativeClientToken = await getClientToken(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not read native Clerk client token.", - cause, - }); - } - } - if (nativeClientToken) { - const saveToken = tokenCache?.saveToken; - if (saveToken) { - try { - await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not save native Clerk client token.", - cause, - }); - } - } - } - - const clerk = getClerkInstance(); - const clerkWithNativeSync = clerk as ClerkWithNativeSync; - const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; - if (reloadInitialResources) { - try { - await reloadInitialResources(); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not reload Clerk resources after native auth.", - cause, - }); - } - } - const setActive = clerkWithNativeSync.setActive; - if (setActive) { - try { - await setActive({ session: sessionId }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Could not activate native Clerk session.", - cause, - }); - } - } -} - -export function useNativeClerkAuthModal() { - const presentingRef = useRef(false); - - const presentAuth = useCallback(async (): Promise => { - if (presentingRef.current || !NativeClerk?.presentAuth) { - return; - } - - presentingRef.current = true; - const presentNativeAuth = NativeClerk.presentAuth; - try { - // Clerk's iOS AuthView is not inline. It presents this same native modal - // internally; call the presenter directly so Expo Router does not render - // an empty formSheet behind it. - let result: NativeAuthResult | null; - try { - result = await presentNativeAuth({ - dismissable: true, - mode: "signInOrUp", - }); - } catch (cause) { - throw new NativeClerkAuthError({ - message: "Native Clerk auth presentation failed.", - cause, - }); - } - const sessionId = result?.sessionId ?? result?.session?.id ?? null; - if (sessionId && !result?.cancelled) { - await syncNativeSession(sessionId); - } - } catch (error) { - if (__DEV__) { - console.error("[useNativeClerkAuthModal] presentAuth failed:", error); - } - } finally { - presentingRef.current = false; - } - }, []); - - return { - isAvailable: !!NativeClerk?.presentAuth, - presentAuth, - }; -} diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index dd26e2e6ffb..dace4c0aaaa 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,4 +1,5 @@ import { SymbolView } from "expo-symbols"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useState } from "react"; import { Pressable, View } from "react-native"; @@ -6,26 +7,17 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { - if (environment.connectionError) { - return null; - } - - switch (environment.connectionState) { - case "ready": - return "Connected"; - case "connecting": - return "Connecting"; - case "reconnecting": - return "Reconnecting"; - case "disconnected": - return null; - case "idle": - return null; - } + return connectionStatusText({ + phase: environment.connectionState, + error: environment.connectionError, + traceId: environment.connectionErrorTraceId, + }); } export function ConnectionEnvironmentRow(props: { @@ -37,7 +29,7 @@ export function ConnectionEnvironmentRow(props: { readonly onUpdate: ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, - ) => void; + ) => Promise; }) { const [label, setLabel] = useState(props.environment.environmentLabel); const [url, setUrl] = useState(props.environment.displayUrl); @@ -47,9 +39,13 @@ export function ConnectionEnvironmentRow(props: { const primaryFg = useThemeColor("--color-primary-foreground"); const dangerFg = useThemeColor("--color-danger-foreground"); const statusLabel = connectionStatusLabel(props.environment); - - const handleSave = useCallback(() => { - props.onUpdate(props.environment.environmentId, { + const statusTraceId = props.environment.connectionErrorTraceId; + const hasConnectionFailure = props.environment.connectionError !== null; + const isRetrying = + props.environment.connectionState === "connecting" || + props.environment.connectionState === "reconnecting"; + const handleSave = useCallback(async () => { + await props.onUpdate(props.environment.environmentId, { label: label.trim(), displayUrl: url.trim(), }); @@ -64,10 +60,7 @@ export function ConnectionEnvironmentRow(props: { > @@ -82,16 +75,35 @@ export function ConnectionEnvironmentRow(props: { {props.environment.displayUrl} {statusLabel ? ( - - {statusLabel} - - ) : null} - {props.environment.connectionError ? ( - {props.environment.connectionError} + {statusLabel} + {statusTraceId ? ( + <> + {" Trace ID: "} + { + event.stopPropagation(); + copyTextWithHaptic(statusTraceId); + }} + onPress={(event) => { + event.stopPropagation(); + }} + style={{ textDecorationStyle: "dotted" }} + > + {statusTraceId} + + + ) : null} ) : null} diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index 60d86e0118c..ce5c6a6419e 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -11,12 +11,19 @@ import Animated, { import type { RemoteClientConnectionState } from "../../lib/connection"; -function statusDotTone(state: RemoteClientConnectionState): { +export type ConnectionStatusDotState = RemoteClientConnectionState; + +function statusDotTone(state: ConnectionStatusDotState): { readonly dotColor: string; readonly haloColor: string; } { switch (state) { - case "ready": + case "available": + return { + dotColor: "#9ca3af", + haloColor: "rgba(156,163,175,0.42)", + }; + case "connected": return { dotColor: "#34d399", haloColor: "rgba(52,211,153,0.48)", @@ -27,8 +34,8 @@ function statusDotTone(state: RemoteClientConnectionState): { dotColor: "#f59e0b", haloColor: "rgba(245,158,11,0.5)", }; - case "idle": - case "disconnected": + case "offline": + case "error": return { dotColor: "#ef4444", haloColor: "rgba(239,68,68,0.48)", @@ -63,7 +70,7 @@ function usePulseAnimation(pulse: boolean) { } export function ConnectionStatusDot(props: { - readonly state: RemoteClientConnectionState; + readonly state: ConnectionStatusDotState; readonly pulse: boolean; readonly size?: number; }) { diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx new file mode 100644 index 00000000000..15852cc3c88 --- /dev/null +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -0,0 +1,108 @@ +import { + type EnvironmentConnectionPhase, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; +import { SymbolView } from "expo-symbols"; +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { useThemeColor } from "../../lib/useThemeColor"; + +function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string { + switch (phase) { + case "offline": + return "You are offline"; + case "connecting": + return `Connecting to ${environmentLabel}...`; + case "reconnecting": + return `Reconnecting to ${environmentLabel}...`; + case "error": + return `${environmentLabel} is unavailable`; + case "available": + return `${environmentLabel} is disconnected`; + case "connected": + return ""; + } +} + +function noticeDetail( + phase: EnvironmentConnectionPhase, + resourceName: string, + error: string | null, +): string { + if (error) { + return `The app will keep retrying automatically. ${error}`; + } + + switch (phase) { + case "offline": + return `Cached data remains available. The ${resourceName} will load when your connection returns.`; + case "connecting": + case "reconnecting": + return `The ${resourceName} will load as soon as the environment is ready.`; + case "available": + case "error": + return `Reconnect the environment to load the ${resourceName}.`; + case "connected": + return ""; + } +} + +export function EnvironmentConnectionNotice(props: { + readonly environmentLabel: string; + readonly connection: EnvironmentConnectionPresentation; + readonly resourceName: string; + readonly onRetry: () => void; +}) { + const iconColor = String(useThemeColor("--color-icon-muted")); + const isRetrying = + props.connection.phase === "connecting" || props.connection.phase === "reconnecting"; + + return ( + + + {isRetrying ? ( + + ) : ( + + )} + + + {noticeTitle(props.connection.phase, props.environmentLabel)} + + + {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} + {props.connection.traceId ? ( + <> + {" Trace ID: "} + copyTextWithHaptic(props.connection.traceId!)} + > + {props.connection.traceId} + + + ) : null} + + + {props.connection.phase !== "offline" ? ( + + Retry now + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts index 5e17b469de2..0de49ceabf6 100644 --- a/apps/mobile/src/features/connection/connectionTone.ts +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -3,7 +3,7 @@ import type { RemoteClientConnectionState } from "../../lib/connection"; export function connectionTone(state: RemoteClientConnectionState): StatusTone { switch (state) { - case "ready": + case "connected": return { label: "Connected", pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16", @@ -21,15 +21,21 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { pillClassName: "bg-sky-500/12 dark:bg-sky-500/16", textClassName: "text-sky-700 dark:text-sky-300", }; - case "disconnected": + case "error": return { - label: "Disconnected", + label: "Connection failed", pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", textClassName: "text-rose-700 dark:text-rose-300", }; - case "idle": + case "offline": return { - label: "Idle", + label: "Offline", + pillClassName: "bg-rose-500/12 dark:bg-rose-500/16", + textClassName: "text-rose-700 dark:text-rose-300", + }; + case "available": + return { + label: "Available", pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16", textClassName: "text-neutral-600 dark:text-neutral-300", }; diff --git a/apps/mobile/src/features/connection/environmentSections.test.ts b/apps/mobile/src/features/connection/environmentSections.test.ts new file mode 100644 index 00000000000..497af4bfac4 --- /dev/null +++ b/apps/mobile/src/features/connection/environmentSections.test.ts @@ -0,0 +1,130 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "vite-plus/test"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; +import { splitEnvironmentSections } from "./environmentSections"; + +function connectedEnvironment( + input: Omit, "environmentId"> & { + readonly environmentId: string; + readonly isRelayManaged: boolean; + }, +): ConnectedEnvironmentSummary { + return { + environmentId: EnvironmentId.make(input.environmentId), + environmentLabel: input.environmentLabel ?? input.environmentId, + displayUrl: input.displayUrl ?? `https://${input.environmentId}.example.test/`, + isRelayManaged: input.isRelayManaged, + connectionState: input.connectionState ?? "connected", + connectionError: input.connectionError ?? null, + connectionErrorTraceId: input.connectionErrorTraceId ?? null, + }; +} + +function cloudEnvironment(environmentId: string): RelayClientEnvironmentRecord { + return { + environmentId: EnvironmentId.make(environmentId), + label: environmentId, + endpoint: { + httpBaseUrl: `https://${environmentId}.cloud.example.test/`, + wsBaseUrl: `wss://${environmentId}.cloud.example.test/ws`, + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("mobile environment settings sections", () => { + it("keeps saved relay-managed connections under T3 Cloud", () => { + const local = connectedEnvironment({ + environmentId: "environment-local", + isRelayManaged: false, + }); + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud, local], + cloudEnvironments: [ + cloudEnvironment("environment-cloud"), + cloudEnvironment("environment-new"), + ], + }); + + expect(sections.localEnvironments).toEqual([local]); + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect( + sections.availableCloudEnvironments.map((environment) => environment.environmentId), + ).toEqual([EnvironmentId.make("environment-new")]); + }); + + it("keeps saved relay-managed connections visible when cloud listing is unavailable", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "reconnecting", + connectionError: "Environment did not respond before the connection timeout.", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: null, + }); + + expect(sections.localEnvironments).toEqual([]); + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("keeps an available saved relay environment as a fallback when listing is unavailable", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "available", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: null, + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("does not duplicate a saved relay environment in the available cloud listing", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "available", + }); + const listedCloud = cloudEnvironment("environment-cloud"); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: [listedCloud], + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); + + it("keeps failed relay environments in the local connection row", () => { + const cloud = connectedEnvironment({ + environmentId: "environment-cloud", + isRelayManaged: true, + connectionState: "error", + connectionError: "Connection failed.", + }); + + const sections = splitEnvironmentSections({ + connectedEnvironments: [cloud], + cloudEnvironments: [cloudEnvironment("environment-cloud")], + }); + + expect(sections.connectedCloudEnvironments).toEqual([cloud]); + expect(sections.availableCloudEnvironments).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/connection/environmentSections.ts b/apps/mobile/src/features/connection/environmentSections.ts new file mode 100644 index 00000000000..fc6db479c2f --- /dev/null +++ b/apps/mobile/src/features/connection/environmentSections.ts @@ -0,0 +1,31 @@ +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; + +export interface EnvironmentSectionsInput { + readonly connectedEnvironments: ReadonlyArray; + readonly cloudEnvironments: ReadonlyArray | null; +} + +export interface EnvironmentSections { + readonly localEnvironments: ReadonlyArray; + readonly connectedCloudEnvironments: ReadonlyArray; + readonly availableCloudEnvironments: ReadonlyArray; +} + +export function splitEnvironmentSections(input: EnvironmentSectionsInput): EnvironmentSections { + const savedEnvironmentIds = new Set( + input.connectedEnvironments.map((environment) => environment.environmentId), + ); + + return { + localEnvironments: input.connectedEnvironments.filter( + (environment) => !environment.isRelayManaged, + ), + connectedCloudEnvironments: input.connectedEnvironments.filter( + (environment) => environment.isRelayManaged, + ), + availableCloudEnvironments: (input.cloudEnvironments ?? []).filter( + (environment) => !savedEnvironmentIds.has(environment.environmentId), + ), + }; +} diff --git a/apps/mobile/src/features/connection/useConnectionController.ts b/apps/mobile/src/features/connection/useConnectionController.ts new file mode 100644 index 00000000000..610bc933742 --- /dev/null +++ b/apps/mobile/src/features/connection/useConnectionController.ts @@ -0,0 +1,97 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { useCallback, useMemo } from "react"; + +import { useEnvironmentActions, useEnvironments } from "../../state/environments"; +import { relayEnvironmentDiscovery } from "../../state/relay"; +import { projectWorkspaceEnvironment, type WorkspaceEnvironment } from "../../state/workspaceModel"; + +export interface RelayEnvironmentView { + readonly environment: RelayClientEnvironmentRecord; + readonly availability: "checking" | "online" | "offline" | "error"; + readonly status: RelayEnvironmentStatusResponse | null; + readonly error: string | null; + readonly traceId: string | null; +} + +export function useConnectionController() { + const { environments } = useEnvironments(); + const actions = useEnvironmentActions(); + const discovery = useAtomValue(relayEnvironmentDiscovery.stateValueAtom); + + const connectedEnvironments = useMemo>( + () => environments.map(projectWorkspaceEnvironment), + [environments], + ); + const registeredIds = useMemo( + () => new Set(connectedEnvironments.map((environment) => environment.environmentId)), + [connectedEnvironments], + ); + const relayEnvironments = useMemo>( + () => + [...discovery.environments.values()].map((entry) => ({ + environment: entry.environment, + availability: entry.availability, + status: Option.getOrNull(entry.status), + error: Option.getOrNull(entry.error)?.message ?? null, + traceId: Option.getOrNull(entry.error)?.traceId ?? null, + })), + [discovery.environments], + ); + const availableRelayEnvironments = useMemo( + () => relayEnvironments.filter((entry) => !registeredIds.has(entry.environment.environmentId)), + [registeredIds, relayEnvironments], + ); + + const connectPairingUrl = useCallback( + (pairingUrl: string) => actions.connectPairingUrl(pairingUrl), + [actions], + ); + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => actions.connectRelayEnvironment(environment), + [actions], + ); + const removeEnvironment = useCallback( + (environmentId: EnvironmentId) => actions.removeEnvironment(environmentId), + [actions], + ); + const retryEnvironment = useCallback( + (environmentId: EnvironmentId) => actions.retryEnvironment(environmentId), + [actions], + ); + const updateEnvironment = useCallback( + ( + environmentId: EnvironmentId, + updates: { readonly label: string; readonly displayUrl: string }, + ) => + actions.updateBearer({ + environmentId, + label: updates.label, + httpBaseUrl: updates.displayUrl, + }), + [actions], + ); + + return { + connectedEnvironments, + relayEnvironments, + availableRelayEnvironments, + relayDiscovery: { + isRefreshing: discovery.refreshing, + isOffline: discovery.offline, + error: Option.getOrNull(discovery.error)?.message ?? null, + errorTraceId: Option.getOrNull(discovery.error)?.traceId ?? null, + }, + connectPairingUrl, + connectRelayEnvironment, + removeEnvironment, + retryEnvironment, + updateEnvironment, + refreshRelayEnvironments: actions.refreshRelayEnvironments, + }; +} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 00e4582957c..9f0f9292baa 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,11 +1,11 @@ -import type { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - VcsStatusState, -} from "@t3tools/client-runtime"; +import { + type EnvironmentProject, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; import { SymbolView } from "expo-symbols"; import { useCallback, useMemo, useState } from "react"; import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -13,29 +13,29 @@ import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; +import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { relativeTime } from "../../lib/time"; -import type { RemoteCatalogState } from "../../state/use-remote-catalog"; -import { useVcsStatus } from "../../state/use-vcs-status"; import { threadStatusTone } from "../threads/threadPresentation"; /* ─── Types ──────────────────────────────────────────────────────────── */ interface HomeScreenProps { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; - readonly catalogState: RemoteCatalogState; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly catalogState: WorkspaceState; readonly savedConnectionsById: Readonly>; readonly searchQuery: string; readonly onAddConnection: () => void; - readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onOpenEnvironments: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; } interface ProjectGroup { readonly key: string; - readonly project: EnvironmentScopedProjectShell; - readonly threads: ReadonlyArray; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; } const projectGroupActivityOrder = Order.mapInput( @@ -49,7 +49,7 @@ const projectGroupActivityOrder = Order.mapInput( /* ─── Status indicator colors ────────────────────────────────────────── */ -function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: string } { +function statusColors(thread: EnvironmentThreadShell): { bg: string; fg: string } { switch (thread.session?.status) { case "running": return { bg: "rgba(249,115,22,0.14)", fg: "#f97316" }; @@ -67,11 +67,11 @@ function statusColors(thread: EnvironmentScopedThreadShell): { bg: string; fg: s const COLLAPSED_THREAD_LIMIT = 6; function deriveEmptyState(props: { - readonly catalogState: RemoteCatalogState; + readonly catalogState: WorkspaceState; readonly projectCount: number; }): { readonly title: string; readonly detail: string; readonly loading: boolean } { const { catalogState } = props; - if (catalogState.isLoadingSavedConnections) { + if (catalogState.isLoadingConnections) { return { title: "Loading environments", detail: "Checking saved environments on this device.", @@ -79,7 +79,7 @@ function deriveEmptyState(props: { }; } - if (!catalogState.hasSavedConnections) { + if (!catalogState.hasConnections) { return { title: "No environments connected", detail: "Add an environment to load projects and start coding sessions.", @@ -87,7 +87,12 @@ function deriveEmptyState(props: { }; } - if (catalogState.connectionState === "disconnected" && !catalogState.hasLoadedShellSnapshot) { + if ( + (catalogState.connectionState === "available" || + catalogState.connectionState === "offline" || + catalogState.connectionState === "error") && + !catalogState.hasLoadedShellSnapshot + ) { return { title: "Environment unavailable", detail: @@ -127,10 +132,11 @@ function deriveEmptyState(props: { /* ─── Project group header ───────────────────────────────────────────── */ function ProjectGroupLabel(props: { - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; readonly totalThreadCount: number; readonly httpBaseUrl: string | null; readonly bearerToken: string | null; + readonly dpopAccessToken?: string; readonly isExpanded: boolean; readonly onToggleExpand: () => void; }) { @@ -144,10 +150,11 @@ function ProjectGroupLabel(props: { httpBaseUrl={props.httpBaseUrl} workspaceRoot={props.project.workspaceRoot} bearerToken={props.bearerToken} + dpopAccessToken={props.dpopAccessToken} /> {props.project.title} @@ -156,8 +163,8 @@ function ProjectGroupLabel(props: { {hiddenCount > 0 ? ( {props.isExpanded ? "Show less" : `${hiddenCount} more`} @@ -167,43 +174,23 @@ function ProjectGroupLabel(props: { ); } -/* ─── Git summary line ──────────────────────────────────────────────── */ - -function gitSummaryParts(gitStatus: VcsStatusState): ReadonlyArray { - if (!gitStatus.data) return []; - const { data } = gitStatus; - const parts: string[] = []; - if (data.hasWorkingTreeChanges) { - parts.push(`${data.workingTree.files.length} changed`); - } - if (data.aheadCount > 0) parts.push(`${data.aheadCount} ahead`); - if (data.behindCount > 0) parts.push(`${data.behindCount} behind`); - if (data.pr?.state === "open") parts.push(`PR #${data.pr.number}`); - return parts; -} - /* ─── Thread row ─────────────────────────────────────────────────────── */ function ThreadRow(props: { - readonly thread: EnvironmentScopedThreadShell; - readonly projectCwd: string | null; + readonly thread: EnvironmentThreadShell; + readonly environmentLabel: string | null; readonly onPress: () => void; readonly isLast: boolean; }) { const separatorColor = useThemeColor("--color-separator"); + const iconSubtleColor = useThemeColor("--color-icon-subtle"); const { bg, fg } = statusColors(props.thread); const tone = threadStatusTone(props.thread); const timestamp = relativeTime(props.thread.updatedAt ?? props.thread.createdAt); const branch = props.thread.branch; - - // Subscribe to live git status — only when thread has a branch set. - // Threads sharing the same cwd share one WS subscription via ref-counting. - const cwd = branch ? (props.thread.worktreePath ?? props.projectCwd) : null; - const gitStatus = useVcsStatus({ - environmentId: cwd ? props.thread.environmentId : null, - cwd, - }); - const gitParts = gitSummaryParts(gitStatus); + const subtitleParts = [props.environmentLabel, branch].filter((part): part is string => + Boolean(part), + ); return ( ({ opacity: pressed ? 0.7 : 1 })}> @@ -261,13 +248,13 @@ function ThreadRow(props: { - {/* Branch + git info */} - {branch ? ( + {/* Environment + branch */} + {subtitleParts.length > 0 ? ( - {branch} + {subtitleParts.join(" · ")} - {gitParts.length > 0 ? ( - - {" · " + gitParts.join(" · ")} - - ) : null} ) : null} @@ -292,8 +274,61 @@ function ThreadRow(props: { /* ─── Main screen ────────────────────────────────────────────────────── */ +function staleCatalogPillLabel(props: { readonly catalogState: WorkspaceState }): string { + if (props.catalogState.networkStatus === "offline") { + return "You are offline"; + } + const connectingEnvironments = props.catalogState.connectingEnvironments; + if (connectingEnvironments.length === 1) { + return `Reconnecting to ${connectingEnvironments[0]!.environmentLabel}`; + } + if (connectingEnvironments.length > 1) { + return `Reconnecting ${connectingEnvironments.length} environments`; + } + return "Not connected"; +} + +function StaleCatalogStatusPill(props: { + readonly catalogState: WorkspaceState; + readonly onPress: () => void; +}) { + const iconColor = useThemeColor("--color-icon-muted"); + const label = staleCatalogPillLabel(props); + const isReconnecting = props.catalogState.connectingEnvironments.length > 0; + + return ( + + {isReconnecting ? ( + + ) : ( + + )} + + {label} + + + ); +} + export function HomeScreen(props: HomeScreenProps) { const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); + const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); const toggleExpanded = useCallback((key: string) => { @@ -327,7 +362,7 @@ export function HomeScreen(props: HomeScreenProps) { /* Group filtered threads by project */ const projectGroups = useMemo>(() => { - const byProject = new Map(); + const byProject = new Map(); for (const thread of filteredThreads) { const key = scopedProjectKey(thread.environmentId, thread.projectId); const existing = byProject.get(key); @@ -350,77 +385,97 @@ export function HomeScreen(props: HomeScreenProps) { /* Empty states */ const hasAnyThreads = props.threads.length > 0; const hasResults = filteredThreads.length > 0; + const shouldShowConnectionStatus = + props.catalogState.networkStatus === "offline" || + props.catalogState.hasConnectingEnvironment || + (props.catalogState.hasLoadedShellSnapshot && !props.catalogState.hasReadyEnvironment); const emptyState = deriveEmptyState({ catalogState: props.catalogState, projectCount: props.projects.length, }); return ( - - {!hasAnyThreads ? ( - - + + {!hasAnyThreads ? ( + + + {emptyState.loading ? ( + + + + ) : null} + + ) : !hasResults ? ( + + ) : ( + projectGroups.map((group) => { + const connection = props.savedConnectionsById[group.project.environmentId]; + const isExpanded = expandedProjects.has(group.key); + const visibleThreads = isExpanded + ? group.threads + : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); + + return ( + + toggleExpanded(group.key)} + /> + + {visibleThreads.map((thread, i) => ( + props.onSelectThread(thread)} + isLast={i === visibleThreads.length - 1} + /> + ))} + + + ); + }) + )} + + {shouldShowConnectionStatus ? ( + + - {emptyState.loading ? ( - - - - ) : null} - ) : !hasResults ? ( - - ) : ( - projectGroups.map((group) => { - const connection = props.savedConnectionsById[group.project.environmentId]; - const isExpanded = expandedProjects.has(group.key); - const visibleThreads = isExpanded - ? group.threads - : group.threads.slice(0, COLLAPSED_THREAD_LIMIT); - - return ( - - toggleExpanded(group.key)} - /> - - {visibleThreads.map((thread, i) => ( - props.onSelectThread(thread)} - isLast={i === visibleThreads.length - 1} - /> - ))} - - - ); - }) - )} - + ) : null} + ); } diff --git a/apps/mobile/src/features/observability/mobileTracing.test.ts b/apps/mobile/src/features/observability/mobileTracing.test.ts deleted file mode 100644 index 0b0f83c6971..00000000000 --- a/apps/mobile/src/features/observability/mobileTracing.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -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"; - -vi.mock("expo-constants", () => ({ - default: { - expoConfig: { - extra: {}, - }, - }, -})); - -it.effect("exports spans through the scoped mobile OTLP layer", () => { - const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); - const tracingLayer = makeMobileTracingLayer( - { - tracesUrl: "https://api.axiom.test/v1/traces", - tracesDataset: "mobile-traces", - tracesToken: "public-ingest-token", - }, - { - appVariant: "test", - serviceVersion: "1.2.3", - }, - ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); - const tracedApplication = Layer.effectDiscard( - Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), - ).pipe(Layer.provide(tracingLayer)); - - return Effect.gen(function* () { - yield* Layer.build(tracedApplication); - - expect(fetchFn).not.toHaveBeenCalled(); - }).pipe( - Effect.scoped, - Effect.andThen( - Effect.sync(() => { - expect(fetchFn).toHaveBeenCalledOnce(); - const [url, init] = fetchFn.mock.calls[0]!; - expect(String(url)).toBe("https://api.axiom.test/v1/traces"); - expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); - expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); - expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); - }), - ), - ); -}); diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts new file mode 100644 index 00000000000..b0deb15be8c --- /dev/null +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -0,0 +1,97 @@ +import { expect, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; + +import { makeTracingLayer } from "./tracing"; + +vi.mock("expo-constants", () => ({ + default: { + expoConfig: { + extra: {}, + }, + }, +})); + +it.effect("exports spans through the scoped mobile OTLP layer", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const tracingLayer = makeTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "mobile-traces", + tracesToken: "public-ingest-token", + }, + { + appVariant: "test", + serviceVersion: "1.2.3", + }, + ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); + const tracedApplication = Layer.effectDiscard( + Effect.void.pipe(Effect.withSpan("mobile.test.span"), withRelayClientTracing), + ).pipe(Layer.provide(tracingLayer)); + + return Effect.gen(function* () { + yield* Layer.build(tracedApplication); + + expect(fetchFn).not.toHaveBeenCalled(); + }).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + const [url, init] = fetchFn.mock.calls[0]!; + expect(String(url)).toBe("https://api.axiom.test/v1/traces"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer public-ingest-token"); + expect(new Headers(init?.headers).get("x-axiom-dataset")).toBe("mobile-traces"); + expect(new TextDecoder().decode(init?.body as Uint8Array)).toContain("mobile.test.span"); + }), + ), + ); +}); + +it.effect("does not let OTLP serialization failures alter application effects", () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 202 })); + const tracingLayer = makeTracingLayer( + { + tracesUrl: "https://api.axiom.test/v1/traces", + tracesDataset: "mobile-traces", + tracesToken: "public-ingest-token", + }, + { + appVariant: "test", + serviceVersion: "1.2.3", + }, + ).pipe(Layer.provide(remoteHttpClientLayer(fetchFn))); + const failure = { durationNanos: 1n }; + const tracedApplication = Layer.effectDiscard( + Effect.fail(failure).pipe( + Effect.withSpan("mobile.test.failed-span"), + withRelayClientTracing, + Effect.exit, + Effect.flatMap((exit) => { + const reason = exit._tag === "Failure" ? exit.cause.reasons[0] : undefined; + return reason && Cause.isFailReason(reason) + ? Effect.sync(() => { + expect(reason.error).toBe(failure); + }) + : Effect.die(new Error("Expected the original typed failure.")); + }), + ), + ).pipe(Layer.provide(tracingLayer)); + + return Layer.build(tracedApplication).pipe( + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + expect(fetchFn).toHaveBeenCalledOnce(); + expect(new TextDecoder().decode(fetchFn.mock.calls[0]?.[1]?.body as Uint8Array)).toContain( + "mobile.test.failed-span", + ); + }), + ), + ); +}); diff --git a/apps/mobile/src/features/observability/mobileTracing.ts b/apps/mobile/src/features/observability/tracing.ts similarity index 64% rename from apps/mobile/src/features/observability/mobileTracing.ts rename to apps/mobile/src/features/observability/tracing.ts index dfc6f875c1b..eb73abba292 100644 --- a/apps/mobile/src/features/observability/mobileTracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -1,32 +1,29 @@ import Constants from "expo-constants"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; -import { hasMobileTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; +import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; -export interface MobileTracingConfig { +export interface TracingConfig { readonly tracesUrl: string; readonly tracesDataset: string; readonly tracesToken: string; } -export interface MobileTracingResource { +export interface TracingResource { readonly serviceVersion?: string; readonly appVariant: string; } -export function resolveMobileTracingConfig(): MobileTracingConfig | null { +export function resolveTracingConfig(): TracingConfig | null { const config = resolveCloudPublicConfig(); - if (!hasMobileTracingPublicConfig(config)) { + if (!hasTracingPublicConfig(config)) { return null; } const { tracesUrl, tracesDataset, tracesToken } = config.observability; return { tracesUrl, tracesDataset, tracesToken }; } -export function makeMobileTracingLayer( - config: MobileTracingConfig | null, - resource: MobileTracingResource, -) { +export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { return makeRelayClientTracingLayer(config, { serviceName: "t3-mobile-relay-client", serviceVersion: resource.serviceVersion, @@ -35,7 +32,7 @@ export function makeMobileTracingLayer( }); } -export const mobileTracingLayer = makeMobileTracingLayer(resolveMobileTracingConfig(), { +export const tracingLayer = makeTracingLayer(resolveTracingConfig(), { serviceVersion: Constants.expoConfig?.version, appVariant: typeof Constants.expoConfig?.extra?.appVariant === "string" diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index a7423966f67..997ae61e298 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -2,24 +2,27 @@ import { addProjectRemoteSourceLabel, addProjectRemoteSourcePathHint, addProjectRemoteSourceProvider, - appendBrowsePathSegment, buildAddProjectRemoteSourceReadiness, buildProjectCreateCommand, - canNavigateUp, - ensureBrowseDirectoryPath, findExistingAddProject, getAddProjectInitialQuery, + resolveAddProjectPath, + sortAddProjectProviderSources, + type AddProjectRemoteSource, +} from "@t3tools/client-runtime/operations/projects"; +import { + appendBrowsePathSegment, + canNavigateUp, + ensureBrowseDirectoryPath, getBrowseDirectoryPath, getBrowseLeafPathSegment, getBrowseParentPath, hasTrailingPathSeparator, inferProjectTitleFromPath, isFilesystemBrowseQuery, - resolveAddProjectPath, - sortAddProjectProviderSources, - type AddProjectRemoteSource, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/projects"; import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; @@ -28,19 +31,17 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; +import { useProjects, useServerConfigs } from "../../state/entities"; +import { filesystemEnvironment } from "../../state/filesystem"; +import { projectEnvironment } from "../../state/projects"; +import { useEnvironmentQuery } from "../../state/query"; +import { sourceControlEnvironment } from "../../state/sourceControl"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ErrorBanner } from "../../components/ErrorBanner"; import { SourceControlIcon } from "../../components/SourceControlIcon"; import { useThemeColor } from "../../lib/useThemeColor"; import { uuidv4 } from "../../lib/uuid"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { useFilesystemBrowse } from "../../state/use-filesystem-browse"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; -import { - refreshSourceControlDiscoveryForEnvironment, - useSourceControlDiscovery, -} from "../../state/use-source-control-discovery"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; interface EnvironmentOption { readonly environmentId: EnvironmentId; @@ -224,12 +225,12 @@ function ProjectPathInput(props: { } function useEnvironmentOptions(): ReadonlyArray { - const { serverConfigByEnvironmentId } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const serverConfigByEnvironmentId = useServerConfigs(); + const { savedConnectionsById } = useSavedRemoteConnections(); return useMemo>(() => { const options = Object.values(savedConnectionsById).map((connection) => { - const config = serverConfigByEnvironmentId[connection.environmentId]; + const config = serverConfigByEnvironmentId.get(connection.environmentId); return { environmentId: connection.environmentId, label: connection.environmentLabel, @@ -336,17 +337,19 @@ export function AddProjectSourceScreen() { const iconColor = useThemeColor("--color-icon"); const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } = useSelectedEnvironment(); - const discoveryState = useSourceControlDiscovery(selectedEnvironment?.environmentId ?? null); + const discoveryState = useEnvironmentQuery( + selectedEnvironment === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: selectedEnvironment.environmentId, + input: {}, + }), + ); const readiness = useMemo( () => buildAddProjectRemoteSourceReadiness(discoveryState.data), [discoveryState.data], ); - useEffect(() => { - if (!selectedEnvironment) return; - void refreshSourceControlDiscoveryForEnvironment(selectedEnvironment.environmentId); - }, [selectedEnvironment]); - return ( {environmentOptions.length === 0 ? : null} @@ -435,13 +438,12 @@ export function AddProjectSourceScreen() { function useCreateProject(environment: EnvironmentOption | null) { const router = useRouter(); - const { projects } = useRemoteCatalog(); + const createProject = useAtomSet(projectEnvironment.create, { mode: "promise" }); + const projects = useProjects(); return useCallback( async (workspaceRoot: string) => { if (!environment) return; - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); const existing = findExistingAddProject({ projects, @@ -462,14 +464,16 @@ function useCreateProject(environment: EnvironmentOption | null) { } const projectId = ProjectId.make(uuidv4()); - await client.orchestration.dispatchCommand( - buildProjectCreateCommand({ - commandId: CommandId.make(uuidv4()), - projectId, - workspaceRoot, - createdAt: new Date().toISOString(), - }), - ); + const command = buildProjectCreateCommand({ + commandId: CommandId.make(uuidv4()), + projectId, + workspaceRoot, + createdAt: new Date().toISOString(), + }); + await createProject({ + environmentId: environment.environmentId, + input: command, + }); router.replace({ pathname: "/new/draft", params: { @@ -479,7 +483,7 @@ function useCreateProject(environment: EnvironmentOption | null) { }, }); }, - [environment, projects, router], + [createProject, environment, projects, router], ); } @@ -495,6 +499,9 @@ function useEnvironmentFromParam(): EnvironmentOption | null { } export function AddProjectRepositoryScreen() { + const lookupRepositoryMutation = useAtomSet(sourceControlEnvironment.lookupRepository, { + mode: "promise", + }); const router = useRouter(); const params = useLocalSearchParams<{ environmentId?: string; source?: string }>(); const environment = useEnvironmentFromParam(); @@ -523,11 +530,12 @@ export function AddProjectRepositoryScreen() { return; } - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); - const repository = await client.sourceControl.lookupRepository({ - provider, - repository: repositoryInput.trim(), + const repository = await lookupRepositoryMutation({ + environmentId: environment.environmentId, + input: { + provider, + repository: repositoryInput.trim(), + }, }); router.push({ pathname: "/new/add-project/destination", @@ -543,7 +551,7 @@ export function AddProjectRepositoryScreen() { } finally { setIsSubmitting(false); } - }, [environment, isSubmitting, repositoryInput, router, source]); + }, [environment, isSubmitting, lookupRepositoryMutation, repositoryInput, router, source]); return ( @@ -593,7 +601,14 @@ function FolderBrowser(props: { () => (browseDirectoryPath.length > 0 ? { partialPath: browseDirectoryPath } : null), [browseDirectoryPath], ); - const browseState = useFilesystemBrowse(props.environment.environmentId, browseInput); + const browseState = useEnvironmentQuery( + browseInput === null + ? null + : filesystemEnvironment.browse({ + environmentId: props.environment.environmentId, + input: browseInput, + }), + ); const visibleBrowseEntries = useMemo( () => Arr.sort( @@ -725,6 +740,9 @@ export function AddProjectLocalFolderScreen() { } export function AddProjectDestinationScreen() { + const cloneRepository = useAtomSet(sourceControlEnvironment.cloneRepository, { + mode: "promise", + }); const params = useLocalSearchParams<{ environmentId?: string; remoteUrl?: string; @@ -760,11 +778,12 @@ export function AddProjectDestinationScreen() { setIsSubmitting(true); try { - const client = getEnvironmentClient(environment.environmentId); - if (!client) throw new Error("Environment API is not available."); - const result = await client.sourceControl.cloneRepository({ - remoteUrl, - destinationPath: resolved.path, + const result = await cloneRepository({ + environmentId: environment.environmentId, + input: { + remoteUrl, + destinationPath: resolved.path, + }, }); await createProject(result.cwd); } catch (nextError) { @@ -772,7 +791,7 @@ export function AddProjectDestinationScreen() { } finally { setIsSubmitting(false); } - }, [createProject, environment, isSubmitting, pathInput, remoteUrl]); + }, [cloneRepository, createProject, environment, isSubmitting, pathInput, remoteUrl]); return ( diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index c82ca71596a..2d7394635e9 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -16,8 +16,11 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/AppText"; +import { useEnvironmentConnectionActions } from "../../state/environments"; +import { useEnvironmentPresentation } from "../../state/presentation"; import { useThemeColor } from "../../lib/useThemeColor"; import { useThreadDraftForThread } from "../../state/use-thread-composer-state"; +import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { useReviewCacheForThread } from "./reviewState"; import { resolveNativeReviewDiffView } from "../diffs/nativeReviewDiffSurface"; import { @@ -114,6 +117,9 @@ export function ReviewSheet() { environmentId: EnvironmentId; threadId: ThreadId; }>(); + const environment = useEnvironmentPresentation(environmentId); + const environmentActions = useEnvironmentConnectionActions(); + const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const selectedTheme = colorScheme === "dark" ? "dark" : "light"; @@ -126,7 +132,12 @@ export function ReviewSheet() { selectedSection, refreshSelectedSection, selectSection, - } = useReviewSections({ environmentId, threadId, reviewCache }); + } = useReviewSections({ + enabled: isEnvironmentReady, + environmentId, + threadId, + reviewCache, + }); const { headerDiffSummary, nativeReviewDiffData, parsedDiff, pendingReviewCommentCount } = useReviewDiffData({ threadKey: reviewCache.threadKey, @@ -187,6 +198,11 @@ export function ReviewSheet() { const parsedDiffNotice = parsedDiff.kind === "files" || parsedDiff.kind === "raw" ? parsedDiff.notice : null; + const hasCachedSelectedDiff = selectedSection?.diff != null; + const showConnectionNotice = environment.isReady && !isEnvironmentReady && !hasCachedSelectedDiff; + const handleRetryEnvironment = useCallback(() => { + void environmentActions.retryNow(environmentId); + }, [environmentActions, environmentId]); const listHeader = useMemo(() => { const children: ReactElement[] = []; @@ -312,34 +328,51 @@ export function ReviewSheet() { }} /> - - - {reviewSections.map((section) => ( + {showConnectionNotice ? null : ( + + + {reviewSections.map((section) => ( + selectSection(section.id)} + subtitle={section.subtitle ?? undefined} + > + {section.title} + + ))} selectSection(section.id)} - subtitle={section.subtitle ?? undefined} + icon="arrow.clockwise" + disabled={ + loadingGitDiffs || + (selectedSection?.kind === "turn" && loadingTurnIds[selectedSection.id] === true) + } + onPress={() => void refreshSelectedSection()} + subtitle="Reload current diff" > - {section.title} + Refresh - ))} - void refreshSelectedSection()} - subtitle="Reload current diff" - > - Refresh - - - + + + )} - {selectedSection && parsedDiff.kind === "files" ? ( + {showConnectionNotice ? ( + + + + ) : selectedSection && parsedDiff.kind === "files" ? ( void; -} - -function makeReviewDiffPreviewKey(input: { - readonly environmentId: EnvironmentId; - readonly cwd: string; -}): string { - return `${input.environmentId}${REVIEW_DIFF_PREVIEW_KEY_SEPARATOR}${input.cwd}`; -} - -function parseReviewDiffPreviewKey(key: string): { - readonly environmentId: EnvironmentId; - readonly cwd: string; -} { - const [environmentId, cwd = ""] = key.split(REVIEW_DIFF_PREVIEW_KEY_SEPARATOR); - return { - environmentId: environmentId as EnvironmentId, - cwd, - }; -} - -const reviewDiffPreviewAtom = Atom.family((key: string) => - Atom.make( - Effect.promise(async (): Promise => { - const target = parseReviewDiffPreviewKey(key); - const client = getEnvironmentClient(target.environmentId); - if (!client) { - throw new Error("Remote connection is not ready."); - } - return client.review.getDiffPreview({ cwd: target.cwd }); - }), - ).pipe( - Atom.swr({ - staleTime: REVIEW_DIFF_PREVIEW_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(REVIEW_DIFF_PREVIEW_IDLE_TTL_MS), - Atom.withLabel(`mobile:review:diff-preview:${key}`), - ), -); - -const EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM = Atom.make( - AsyncResult.initial(false), -).pipe(Atom.keepAlive, Atom.withLabel("mobile:review:diff-preview:null")); - -function readReviewDiffPreviewError( - result: AsyncResult.AsyncResult, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const error = Cause.squash(result.cause); - return error instanceof Error ? error.message : "Failed to load review diffs."; -} - -export function useReviewDiffPreview(input: { - readonly environmentId?: EnvironmentId; - readonly cwd: string | null; -}): ReviewDiffPreviewState { - const key = useMemo(() => { - if (!input.environmentId || !input.cwd) { - return null; - } - return makeReviewDiffPreviewKey({ environmentId: input.environmentId, cwd: input.cwd }); - }, [input.cwd, input.environmentId]); - - const atom = key ? reviewDiffPreviewAtom(key) : null; - const result = useAtomValue(atom ?? EMPTY_REVIEW_DIFF_PREVIEW_RESULT_ATOM); - const refresh = useCallback(() => { - if (atom) { - appAtomRegistry.refresh(atom); - } - }, [atom]); - - if (!atom) { - return { - data: null, - error: null, - isPending: false, - refresh, - }; - } - - return { - data: Option.getOrNull(AsyncResult.value(result)), - error: readReviewDiffPreviewError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index fedf6e10b96..0bc6469426e 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightReviewFile } from "./shikiReviewHighlighter"; +import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; function makeRenderableFile( input: Partial & Pick, @@ -119,3 +119,22 @@ describe("highlightReviewFile", () => { ]); }); }); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", + theme: "dark", + }); + + expect( + highlighted + .flat() + .map((token) => token.content) + .join(""), + ).toBe(source); + expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 8e254fbb0b3..d6d09221dac 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -685,6 +685,16 @@ async function highlightLines( return highlightedLines; } +export async function highlightCodeSnippet(input: { + readonly code: string; + readonly language?: string | null; + readonly theme: ReviewDiffTheme; +}): Promise>> { + const languageHint = input.language?.trim() || "text"; + const language = await resolveLanguageFromPath(`snippet.${languageHint}`, languageHint); + return highlightLines(input.code, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); +} + async function highlightPatchLinesInChunks(input: { readonly lines: ReadonlyArray; readonly language: string; diff --git a/apps/mobile/src/features/review/useReviewSections.ts b/apps/mobile/src/features/review/useReviewSections.ts index 4c5a1abffb1..87325490990 100644 --- a/apps/mobile/src/features/review/useReviewSections.ts +++ b/apps/mobile/src/features/review/useReviewSections.ts @@ -1,12 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import type { EnvironmentId, OrchestrationCheckpointSummary, ThreadId } from "@t3tools/contracts"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { checkpointDiffManager, loadCheckpointDiff } from "../../state/use-checkpoint-diff"; -import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; +import { useCheckpointDiff } from "../../state/queries"; +import { useEnvironmentQuery } from "../../state/query"; +import { reviewEnvironment } from "../../state/review"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; -import { useReviewDiffPreview } from "./reviewDiffPreviewState"; +import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { buildReviewSectionItems, getDefaultReviewSectionId, @@ -17,29 +17,30 @@ import { setReviewAsyncError, setReviewGitSections, setReviewSelectedSectionId, - setReviewTurnDiffLoading, setReviewTurnDiff, + setReviewTurnDiffLoading, type ReviewCacheForThread, } from "./reviewState"; export function useReviewSections(input: { + readonly enabled?: boolean; readonly environmentId?: EnvironmentId; readonly threadId?: ThreadId; readonly reviewCache: ReviewCacheForThread; }) { const { environmentId, reviewCache, threadId } = input; + const enabled = input.enabled ?? true; const selectedThread = useSelectedThreadDetail(); const { selectedThreadCwd } = useSelectedThreadWorktree(); - const diffPreview = useReviewDiffPreview({ environmentId, cwd: selectedThreadCwd }); - const refreshDiffPreview = diffPreview.refresh; + const diffPreview = useEnvironmentQuery( + enabled && environmentId !== undefined && selectedThreadCwd !== null + ? reviewEnvironment.diffPreview({ + environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const { loadingTurnIds } = reviewCache.asyncState; - const error = diffPreview.error ?? reviewCache.asyncState.error; - const loadingGitDiffs = diffPreview.isPending; - const turnDiffByIdRef = useRef(reviewCache.turnDiffById); - - useEffect(() => { - turnDiffByIdRef.current = reviewCache.turnDiffById; - }, [reviewCache.turnDiffById]); useEffect(() => { if (reviewCache.threadKey && diffPreview.data) { @@ -51,14 +52,16 @@ export function useReviewSections(input: { () => getReadyReviewCheckpoints(selectedThread?.checkpoints ?? []), [selectedThread?.checkpoints], ); - const checkpointBySectionId = useMemo(() => { - return Object.fromEntries( - readyCheckpoints.map((checkpoint) => [ - getReviewSectionIdForCheckpoint(checkpoint), - checkpoint, - ]), - ) as Record; - }, [readyCheckpoints]); + const checkpointBySectionId = useMemo( + () => + Object.fromEntries( + readyCheckpoints.map((checkpoint) => [ + getReviewSectionIdForCheckpoint(checkpoint), + checkpoint, + ]), + ) as Record, + [readyCheckpoints], + ); const reviewSections = useMemo( () => buildReviewSectionItems({ @@ -87,7 +90,6 @@ export function useReviewSections(input: { () => getDefaultReviewSectionId(reviewSections), [reviewSections], ); - const hasReviewSections = reviewSections.length > 0; const selectedSectionIdExists = useMemo( () => reviewCache.selectedSectionId @@ -96,140 +98,69 @@ export function useReviewSections(input: { [reviewCache.selectedSectionId, reviewSections], ); - const loadTurnDiff = useCallback( - async (checkpoint: OrchestrationCheckpointSummary, force = false) => { - if (!environmentId || !threadId) { - return; - } - - const sectionId = getReviewSectionIdForCheckpoint(checkpoint); - if (reviewCache.threadKey) { - setReviewSelectedSectionId(reviewCache.threadKey, sectionId); - } - - if (!force && turnDiffByIdRef.current[sectionId] !== undefined) { - return; - } - - const target = { - environmentId, - threadId, - fromTurnCount: Math.max(0, checkpoint.checkpointTurnCount - 1), - toTurnCount: checkpoint.checkpointTurnCount, - ignoreWhitespace: false, - cacheScope: sectionId, - }; - const cached = checkpointDiffManager.getSnapshot(target).data; - if (!force && cached) { - if (reviewCache.threadKey) { - setReviewTurnDiff(reviewCache.threadKey, sectionId, cached.diff); - } - return; - } - - if (!getEnvironmentClient(environmentId)) { - if (reviewCache.threadKey) { - setReviewAsyncError(reviewCache.threadKey, "Remote connection is not ready."); - } - return; - } - - if (reviewCache.threadKey) { - setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, true); - setReviewAsyncError(reviewCache.threadKey, null); - } - try { - const result = await loadCheckpointDiff(target, { force }); - if (reviewCache.threadKey) { - if (result) { - setReviewTurnDiff(reviewCache.threadKey, sectionId, result.diff); - } - } - } catch (cause) { - if (reviewCache.threadKey) { - setReviewAsyncError( - reviewCache.threadKey, - cause instanceof Error ? cause.message : "Failed to load turn diff.", - ); - } - } finally { - if (reviewCache.threadKey) { - setReviewTurnDiffLoading(reviewCache.threadKey, sectionId, false); - } - } - }, - [environmentId, reviewCache.threadKey, threadId], - ); - useEffect(() => { - if (!hasReviewSections) { - return; - } - - if (reviewCache.threadKey && (!reviewCache.selectedSectionId || !selectedSectionIdExists)) { + if ( + reviewSections.length > 0 && + reviewCache.threadKey && + (!reviewCache.selectedSectionId || !selectedSectionIdExists) + ) { setReviewSelectedSectionId(reviewCache.threadKey, fallbackSectionId); } }, [ fallbackSectionId, - hasReviewSections, reviewCache.selectedSectionId, reviewCache.threadKey, + reviewSections.length, selectedSectionIdExists, ]); - const latestCheckpoint = readyCheckpoints[0] ?? null; - const latestSectionId = latestCheckpoint - ? getReviewSectionIdForCheckpoint(latestCheckpoint) + let activeCheckpoint = readyCheckpoints[0] ?? null; + if (selectedSection?.kind === "turn") { + activeCheckpoint = checkpointBySectionId[selectedSection.id] ?? activeCheckpoint; + } + const activeSectionId = activeCheckpoint + ? getReviewSectionIdForCheckpoint(activeCheckpoint) : null; - const latestTurnDiffLoaded = latestSectionId - ? reviewCache.turnDiffById[latestSectionId] !== undefined - : true; - const latestTurnDiffLoading = latestSectionId ? loadingTurnIds[latestSectionId] === true : false; + const activeTurnDiff = useCheckpointDiff({ + environmentId: enabled ? (environmentId ?? null) : null, + threadId: enabled ? (threadId ?? null) : null, + fromTurnCount: + enabled && activeCheckpoint ? Math.max(0, activeCheckpoint.checkpointTurnCount - 1) : null, + toTurnCount: enabled ? (activeCheckpoint?.checkpointTurnCount ?? null) : null, + ignoreWhitespace: false, + }); useEffect(() => { - if (!latestCheckpoint || !latestSectionId || latestTurnDiffLoaded || latestTurnDiffLoading) { + if (!reviewCache.threadKey || !activeSectionId) { return; } - - void loadTurnDiff(latestCheckpoint); - }, [ - latestCheckpoint, - latestSectionId, - latestTurnDiffLoaded, - latestTurnDiffLoading, - loadTurnDiff, - ]); - - const selectedTurnCheckpoint = - selectedSection?.kind === "turn" ? (checkpointBySectionId[selectedSection.id] ?? null) : null; - const selectedTurnDiffMissing = - selectedSection?.kind === "turn" && selectedSection.diff === null && selectedTurnCheckpoint; - const selectedTurnDiffLoading = - selectedSection?.kind === "turn" ? loadingTurnIds[selectedSection.id] === true : false; + setReviewTurnDiffLoading(reviewCache.threadKey, activeSectionId, activeTurnDiff.isPending); + }, [activeSectionId, activeTurnDiff.isPending, reviewCache.threadKey]); useEffect(() => { - if (!selectedTurnDiffMissing || selectedTurnDiffLoading) { + if (!reviewCache.threadKey || !activeSectionId || !activeTurnDiff.data) { return; } + setReviewTurnDiff(reviewCache.threadKey, activeSectionId, activeTurnDiff.data.diff); + setReviewAsyncError(reviewCache.threadKey, null); + }, [activeSectionId, activeTurnDiff.data, reviewCache.threadKey]); - void loadTurnDiff(selectedTurnDiffMissing); - }, [loadTurnDiff, selectedTurnDiffLoading, selectedTurnDiffMissing]); + useEffect(() => { + if (reviewCache.threadKey && activeTurnDiff.error) { + setReviewAsyncError(reviewCache.threadKey, activeTurnDiff.error); + } + }, [activeTurnDiff.error, reviewCache.threadKey]); const refreshSelectedSection = useCallback(async () => { - if (!selectedSection) { + if (!enabled) { return; } - - if (selectedSection.kind === "turn") { - const checkpoint = checkpointBySectionId[selectedSection.id]; - if (checkpoint) { - await loadTurnDiff(checkpoint, true); - } + if (selectedSection?.kind === "turn") { + activeTurnDiff.refresh(); return; } - - refreshDiffPreview(); - }, [checkpointBySectionId, loadTurnDiff, refreshDiffPreview, selectedSection]); + diffPreview.refresh(); + }, [activeTurnDiff, diffPreview, enabled, selectedSection?.kind]); const selectSection = useCallback( (sectionId: string) => { @@ -241,8 +172,8 @@ export function useReviewSections(input: { ); return { - error, - loadingGitDiffs, + error: diffPreview.error ?? activeTurnDiff.error ?? reviewCache.asyncState.error, + loadingGitDiffs: diffPreview.isPending, loadingTurnIds, reviewSections, selectedSection, diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index 71643336d54..647388c2a42 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -1,18 +1,14 @@ +import { useAtomSet } from "@effect/atom-react"; import { DEFAULT_TERMINAL_ID, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; import { SymbolView } from "expo-symbols"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { - attachTerminalSession, - useTerminalSession, - useTerminalSessionTarget, -} from "../../state/use-terminal-session"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAttachedTerminalSession } from "../../state/use-terminal-session"; import { TerminalSurface } from "./NativeTerminalSurface"; import { hasNativeTerminalSurface } from "./nativeTerminalModule"; -import { terminalDebugLog } from "./terminalDebugLog"; interface ThreadTerminalPanelProps { readonly environmentId: EnvironmentId; @@ -29,79 +25,60 @@ const DEFAULT_TERMINAL_ROWS = 24; export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( props: ThreadTerminalPanelProps, ) { + const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" }); + const resizeTerminal = useAtomSet(terminalEnvironment.resize, { mode: "promise" }); const nativeTerminalAvailable = hasNativeTerminalSurface(); const terminalId = DEFAULT_TERMINAL_ID; - const target = useTerminalSessionTarget({ - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - }); - const terminal = useTerminalSession(target); const [lastGridSize, setLastGridSize] = useState({ cols: DEFAULT_TERMINAL_COLS, rows: DEFAULT_TERMINAL_ROWS, }); - const lastGridSizeRef = useRef(lastGridSize); - lastGridSizeRef.current = lastGridSize; + const attachInput = useMemo( + () => + props.visible + ? { + threadId: props.threadId, + terminalId, + cwd: props.cwd, + worktreePath: props.worktreePath, + cols: lastGridSize.cols, + rows: lastGridSize.rows, + } + : null, + [ + lastGridSize.cols, + lastGridSize.rows, + props.cwd, + props.threadId, + props.visible, + props.worktreePath, + terminalId, + ], + ); + const terminal = useAttachedTerminalSession({ + environmentId: props.environmentId, + terminal: attachInput, + }); const terminalKey = `${props.environmentId}:${props.threadId}:${terminalId}`; const isRunning = terminal.status === "running" || terminal.status === "starting"; - useEffect(() => { - if (!props.visible) { - return; - } - - const client = getEnvironmentClient(props.environmentId); - if (!client) { - terminalDebugLog("panel:attach-skip", { - reason: "no-environment-client", - environmentId: props.environmentId, - }); - return; - } - - terminalDebugLog("panel:attach", { - environmentId: props.environmentId, - threadId: props.threadId, - terminalId, - }); - - return attachTerminalSession({ - environmentId: props.environmentId, - client, - terminal: { - threadId: props.threadId, - terminalId, - cwd: props.cwd, - worktreePath: props.worktreePath, - cols: lastGridSizeRef.current.cols, - rows: lastGridSizeRef.current.rows, - }, - }); - }, [ - props.cwd, - props.environmentId, - props.threadId, - props.worktreePath, - props.visible, - terminalId, - ]); - const handleInput = useCallback( (data: string) => { - const client = getEnvironmentClient(props.environmentId); - if (!client || !isRunning) { + if (!isRunning) { return; } - void client.terminal.write({ - threadId: props.threadId, - terminalId, - data, + void writeTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + data, + }, }); }, - [isRunning, props.environmentId, props.threadId, terminalId], + [isRunning, props.environmentId, props.threadId, terminalId, writeTerminal], ); const handleResize = useCallback( @@ -111,16 +88,18 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( } setLastGridSize(size); - const client = getEnvironmentClient(props.environmentId); - if (!client || !isRunning) { + if (!isRunning) { return; } - void client.terminal.resize({ - threadId: props.threadId, - terminalId, - cols: size.cols, - rows: size.rows, + void resizeTerminal({ + environmentId: props.environmentId, + input: { + threadId: props.threadId, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); }, [ @@ -129,6 +108,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( lastGridSize.rows, props.environmentId, props.threadId, + resizeTerminal, terminalId, ], ); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index e4ac3cc5c8b..2e8549de55d 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1,10 +1,6 @@ -import { - DEFAULT_TERMINAL_ID, - EnvironmentId, - type TerminalAttachStreamEvent, - ThreadId, -} from "@t3tools/contracts"; -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { useAtomSet } from "@effect/atom-react"; +import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { SymbolView } from "expo-symbols"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -24,17 +20,18 @@ import { import { EmptyState } from "../../components/EmptyState"; import { GlassSurface } from "../../components/GlassSurface"; import { LoadingScreen } from "../../components/LoadingScreen"; +import { useEnvironmentConnectionActions } from "../../state/environments"; +import { useEnvironmentPresentation } from "../../state/presentation"; +import { terminalEnvironment } from "../../state/terminal"; +import { useWorkspaceState } from "../../state/workspace"; import { buildThreadTerminalNavigation } from "../../lib/routes"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { useRemoteEnvironmentState } from "../../state/use-remote-environment-registry"; import { - attachTerminalSession, + useAttachedTerminalSession, useKnownTerminalSessions, - useTerminalSession, - useTerminalSessionTarget, } from "../../state/use-terminal-session"; import { useThreadSelection } from "../../state/use-thread-selection"; import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice"; import { TerminalSurface } from "./NativeTerminalSurface"; import { getPierreTerminalTheme } from "./terminalTheme"; import { loadPreferences, savePreferencesPatch } from "../../lib/storage"; @@ -44,11 +41,10 @@ import { getTerminalSurfaceReplayBuffer, TERMINAL_BUFFER_REPLAY_STABILITY_DELAY_MS, } from "./terminalBufferReplay"; -import { resolveTerminalRouteBootstrap } from "./terminalRouteBootstrap"; import { resolveTerminalOpenLocation, - stagePendingTerminalLaunch, takePendingTerminalLaunch, + type PendingTerminalLaunch, } from "./terminalLaunchContext"; import { basename, @@ -158,8 +154,12 @@ function pickRunningTerminalSessionForBootstrap( export function ThreadTerminalRouteScreen() { const router = useRouter(); + const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" }); + const resizeTerminal = useAtomSet(terminalEnvironment.resize, { mode: "promise" }); + const clearTerminal = useAtomSet(terminalEnvironment.clear, { mode: "promise" }); + const environmentActions = useEnvironmentConnectionActions(); const appearanceScheme = useColorScheme() === "light" ? "light" : "dark"; - const { isLoadingSavedConnection } = useRemoteEnvironmentState(); + const { state: workspaceState } = useWorkspaceState(); const params = useLocalSearchParams<{ environmentId?: string | string[]; threadId?: string | string[]; @@ -174,6 +174,8 @@ export function ThreadTerminalRouteScreen() { ? EnvironmentId.make(routeEnvironmentIdRaw) : null; const routeThreadId = routeThreadIdRaw ? ThreadId.make(routeThreadIdRaw) : null; + const environment = useEnvironmentPresentation(routeEnvironmentId); + const isEnvironmentReady = environment.presentation?.connection.phase === "connected"; const requestedTerminalId = firstRouteParam(params.terminalId); const terminalId = requestedTerminalId ?? DEFAULT_TERMINAL_ID; const cachedFontSize = getCachedTerminalFontSize(); @@ -189,6 +191,47 @@ export function ThreadTerminalRouteScreen() { environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); + const runningSession = useMemo( + () => pickRunningTerminalSessionForBootstrap(knownSessions), + [knownSessions], + ); + const activeKnownSession = useMemo( + () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, + [knownSessions, terminalId], + ); + const launchTarget = useMemo( + () => + selectedThread + ? { + environmentId: selectedThread.environmentId, + threadId: selectedThread.id, + terminalId, + } + : null, + [selectedThread?.environmentId, selectedThread?.id, terminalId], + ); + const launchTargetKey = launchTarget + ? `${launchTarget.environmentId}:${launchTarget.threadId}:${launchTarget.terminalId}` + : null; + const [pendingLaunchEntry, setPendingLaunchEntry] = useState<{ + readonly key: string | null; + readonly launch: PendingTerminalLaunch | null; + }>(() => ({ + key: launchTargetKey, + launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), + })); + const pendingLaunch = + pendingLaunchEntry.key === launchTargetKey ? pendingLaunchEntry.launch : null; + const hasResolvedPendingLaunch = pendingLaunchEntry.key === launchTargetKey; + const [initialAttachGridEntry, setInitialAttachGridEntry] = useState(() => ({ + key: launchTargetKey, + size: cachedRouteGridSize ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + })); + const initialAttachGridSize = + initialAttachGridEntry.key === launchTargetKey ? initialAttachGridEntry.size : null; const [lastGridSize, setLastGridSize] = useState( cachedRouteGridSize ?? { cols: DEFAULT_TERMINAL_COLS, @@ -198,11 +241,10 @@ export function ThreadTerminalRouteScreen() { const [fontSize, setFontSize] = useState(cachedFontSize ?? DEFAULT_TERMINAL_FONT_SIZE); const [keyboardFocusRequest, setKeyboardFocusRequest] = useState(0); const [isAccessoryDismissed, setIsAccessoryDismissed] = useState(false); - const hasOpenedRef = useRef(false); const bufferReplayTimerRef = useRef | null>(null); - const attachStreamLogCountRef = useRef(0); const firstNonEmptyBufferLoggedRef = useRef(false); const lastBufferReplayKeyRef = useRef(null); + const sentInitialInputKeyRef = useRef(null); const [readyBufferReplayKey, setReadyBufferReplayKey] = useState(null); const [hasResolvedFontPreference, setHasResolvedFontPreference] = useState( cachedFontSize !== null, @@ -216,12 +258,78 @@ export function ThreadTerminalRouteScreen() { terminalId, value: null, }); - const target = useTerminalSessionTarget({ + const shouldRedirectToRunningTerminal = + requestedTerminalId === null && + runningSession !== null && + runningSession.target.terminalId !== terminalId; + const launchLocationCandidate = useMemo(() => { + if (!selectedThread || !selectedThreadProject?.workspaceRoot) { + return null; + } + if (pendingLaunch) { + return { + cwd: pendingLaunch.cwd, + worktreePath: pendingLaunch.worktreePath, + }; + } + return resolveTerminalOpenLocation({ + terminalLocation: activeKnownSession?.state.summary ?? null, + activeSessionLocation: activeKnownSession?.state.summary ?? null, + workspaceRoot: selectedThreadProject.workspaceRoot, + threadShellWorktreePath: selectedThread.worktreePath ?? null, + threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, + }); + }, [ + activeKnownSession?.state.summary, + pendingLaunch, + selectedThread, + selectedThreadDetail?.worktreePath, + selectedThreadProject?.workspaceRoot, + ]); + const [initialLaunchLocationEntry, setInitialLaunchLocationEntry] = useState(() => ({ + key: launchTargetKey, + location: launchLocationCandidate, + })); + const launchLocation = + initialLaunchLocationEntry.key === launchTargetKey ? initialLaunchLocationEntry.location : null; + const terminalAttachInput = useMemo( + () => + selectedThread !== null && + launchLocation !== null && + hasResolvedPendingLaunch && + initialAttachGridSize !== null && + hasResolvedFontPreference && + hasMeasuredSurface && + isEnvironmentReady && + !shouldRedirectToRunningTerminal + ? { + threadId: selectedThread.id, + terminalId, + cwd: launchLocation.cwd, + worktreePath: launchLocation.worktreePath, + cols: initialAttachGridSize.cols, + rows: initialAttachGridSize.rows, + ...(pendingLaunch?.env ? { env: pendingLaunch.env } : {}), + ...(pendingLaunch ? { restartIfNotRunning: true } : {}), + } + : null, + [ + hasMeasuredSurface, + hasResolvedFontPreference, + hasResolvedPendingLaunch, + initialAttachGridSize, + isEnvironmentReady, + launchLocation, + pendingLaunch, + selectedThread, + shouldRedirectToRunningTerminal, + terminalId, + ], + ); + const terminal = useAttachedTerminalSession({ environmentId: selectedThread?.environmentId ?? null, - threadId: selectedThread?.id ?? null, - terminalId, + terminal: terminalAttachInput, }); - const terminal = useTerminalSession(target); const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` : terminalId; @@ -293,23 +401,6 @@ export function ThreadTerminalRouteScreen() { () => inferHostPlatform(selectedEnvironmentConnection?.environmentLabel ?? null), [selectedEnvironmentConnection?.environmentLabel], ); - const runningSession = useMemo( - () => pickRunningTerminalSessionForBootstrap(knownSessions), - [knownSessions], - ); - const activeKnownSession = useMemo( - () => knownSessions.find((session) => session.target.terminalId === terminalId) ?? null, - [knownSessions, terminalId], - ); - - const terminalAttachLaunchHintsRef = useRef({ - terminalSummary: terminal.summary, - activeKnownSummary: activeKnownSession?.state.summary ?? null, - }); - terminalAttachLaunchHintsRef.current = { - terminalSummary: terminal.summary, - activeKnownSummary: activeKnownSession?.state.summary ?? null, - }; const terminalTheme = getPierreTerminalTheme(appearanceScheme); const pendingModifier = @@ -406,145 +497,88 @@ export function ThreadTerminalRouteScreen() { ], ); - const logAttachStreamEvent = useCallback((event: TerminalAttachStreamEvent) => { - const n = ++attachStreamLogCountRef.current; - if (event.type === "output" && n > 32 && n % 64 !== 0) { + useEffect(() => { + if (pendingLaunchEntry.key === launchTargetKey) { return; } - if (event.type === "snapshot") { - terminalDebugLog("attach:stream", { - n, - type: event.type, - status: event.snapshot.status, - historyLen: event.snapshot.history.length, - cwd: event.snapshot.cwd, - }); + setPendingLaunchEntry({ + key: launchTargetKey, + launch: launchTarget === null ? null : takePendingTerminalLaunch(launchTarget), + }); + }, [launchTarget, launchTargetKey, pendingLaunchEntry.key]); + + useEffect(() => { + if (initialAttachGridEntry.key === launchTargetKey) { return; } - if (event.type === "output") { - terminalDebugLog("attach:stream", { n, type: event.type, dataLen: event.data.length }); + setInitialAttachGridEntry({ + key: launchTargetKey, + size: cachedRouteGridSize ?? { + cols: DEFAULT_TERMINAL_COLS, + rows: DEFAULT_TERMINAL_ROWS, + }, + }); + }, [cachedRouteGridSize, initialAttachGridEntry.key, launchTargetKey]); + + useEffect(() => { + if ( + initialLaunchLocationEntry.key === launchTargetKey && + initialLaunchLocationEntry.location !== null + ) { return; } - terminalDebugLog("attach:stream", { n, type: event.type }); - }, []); - - const attachTerminal = useCallback(() => { - if (!selectedThread || !selectedThreadProject?.workspaceRoot) { - terminalDebugLog("attach:abort", { reason: "no-thread-or-workspace" }); - return null; + if (initialLaunchLocationEntry.key === launchTargetKey && launchLocationCandidate === null) { + return; } + setInitialLaunchLocationEntry({ + key: launchTargetKey, + location: launchLocationCandidate, + }); + }, [ + initialLaunchLocationEntry.key, + initialLaunchLocationEntry.location, + launchLocationCandidate, + launchTargetKey, + ]); - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - terminalDebugLog("attach:abort", { - reason: "no-environment-client", - environmentId: selectedThread.environmentId, - }); - return null; + useEffect(() => { + if (!shouldRedirectToRunningTerminal || !selectedThread || !runningSession) { + return; } + router.replace(buildThreadTerminalNavigation(selectedThread, runningSession.target.terminalId)); + }, [router, runningSession, selectedThread, shouldRedirectToRunningTerminal]); - const pendingLaunchTarget = { + useEffect(() => { + const initialInput = pendingLaunch?.initialInput; + if ( + !initialInput || + !selectedThread || + terminal.version === 0 || + sentInitialInputKeyRef.current === launchTargetKey + ) { + return; + } + sentInitialInputKeyRef.current = launchTargetKey; + void writeTerminal({ environmentId: selectedThread.environmentId, - threadId: selectedThread.id, - terminalId, - }; - const pendingLaunch = takePendingTerminalLaunch(pendingLaunchTarget); - let initialInputSent = false; - - try { - const launchLocation = pendingLaunch - ? { - cwd: pendingLaunch.cwd, - worktreePath: pendingLaunch.worktreePath, - } - : resolveTerminalOpenLocation({ - terminalLocation: terminalAttachLaunchHintsRef.current.terminalSummary, - activeSessionLocation: terminalAttachLaunchHintsRef.current.activeKnownSummary, - workspaceRoot: selectedThreadProject.workspaceRoot, - threadShellWorktreePath: selectedThread.worktreePath ?? null, - threadDetailWorktreePath: selectedThreadDetail?.worktreePath ?? null, - }); - - terminalDebugLog("attach:start", { - terminalId, + input: { threadId: selectedThread.id, - cols: lastGridSize.cols, - rows: lastGridSize.rows, - cwd: launchLocation.cwd, - worktreePath: launchLocation.worktreePath, - }); - - return attachTerminalSession({ - environmentId: selectedThread.environmentId, - client, - terminal: { - threadId: selectedThread.id, - terminalId, - cwd: launchLocation.cwd, - worktreePath: launchLocation.worktreePath, - cols: lastGridSize.cols, - rows: lastGridSize.rows, - env: pendingLaunch?.env, - ...(pendingLaunch ? { restartIfNotRunning: true } : {}), - }, - onEvent: logAttachStreamEvent, - onSnapshot: () => { - if (!pendingLaunch?.initialInput || initialInputSent) { - return; - } - - initialInputSent = true; - void client.terminal.write({ - threadId: selectedThread.id, - terminalId, - data: pendingLaunch.initialInput, - }); - }, - }); - } catch (error) { - terminalDebugLog("attach:error", { - message: error instanceof Error ? error.message : String(error), - }); - if (pendingLaunch) { - stagePendingTerminalLaunch({ - target: pendingLaunchTarget, - launch: pendingLaunch, - }); - } - - throw error; - } + terminalId, + data: initialInput, + }, + }); }, [ - lastGridSize.cols, - lastGridSize.rows, - logAttachStreamEvent, - selectedThreadDetail?.worktreePath, + launchTargetKey, + pendingLaunch?.initialInput, selectedThread, - selectedThreadProject?.workspaceRoot, + terminal.version, terminalId, + writeTerminal, ]); - const attachTerminalRef = useRef(attachTerminal); - attachTerminalRef.current = attachTerminal; - const selectedThreadRef = useRef(selectedThread); - selectedThreadRef.current = selectedThread; - const selectedThreadProjectBootstrapRef = useRef(selectedThreadProject); - selectedThreadProjectBootstrapRef.current = selectedThreadProject; - const runningSessionRef = useRef(runningSession); - runningSessionRef.current = runningSession; - const terminalBootstrapRef = useRef({ - status: terminal.status, - bufferLen: terminal.buffer.length, - }); - terminalBootstrapRef.current = { - status: terminal.status, - bufferLen: terminal.buffer.length, - }; - useEffect(() => { - hasOpenedRef.current = false; - attachStreamLogCountRef.current = 0; firstNonEmptyBufferLoggedRef.current = false; + sentInitialInputKeyRef.current = null; }, [terminalKey]); const clearBufferReplayTimer = useCallback(() => { @@ -638,99 +672,22 @@ export function ThreadTerminalRouteScreen() { }); }, [fontSize, hasResolvedFontPreference]); - // Subscribes `terminal.attach` once per route+terminal until thread/env/attach args change. - // Use refs for `attachTerminal` / `selectedThread` / `runningSession`: their identities change when - // unrelated store updates (e.g. terminal buffer) re-render the parent, which was firing cleanup - // → detach immediately after the first snapshot. - useEffect(() => { - if (!hasResolvedFontPreference || !hasMeasuredSurface) { - return; - } - - const thread = selectedThreadRef.current; - const project = selectedThreadProjectBootstrapRef.current; - const running = runningSessionRef.current; - const termSnap = terminalBootstrapRef.current; - - const bootstrapAction = resolveTerminalRouteBootstrap({ - hasThread: thread !== null, - hasWorkspaceRoot: Boolean(project?.workspaceRoot), - hasOpened: hasOpenedRef.current, - requestedTerminalId, - currentTerminalId: terminalId, - runningTerminalId: running?.target.terminalId ?? null, - currentTerminalStatus: termSnap.status, - // Metadata summary (cwd/status) is not scrollback. Only `terminal.attach` fills `buffer`; - // treating summary as "hydrated" skipped attach while status was running → empty surface. - hasCurrentTerminalHydration: termSnap.bufferLen > 0, - }); - if (bootstrapAction.kind !== "idle") { - terminalDebugLog("bootstrap:action", { - kind: bootstrapAction.kind, - hasOpenedBefore: hasOpenedRef.current, - hasHydration: termSnap.bufferLen > 0, - terminalStatus: termSnap.status, - bufLen: termSnap.bufferLen, - }); - } - if (bootstrapAction.kind === "idle" || !thread) { - return; - } - - if (bootstrapAction.kind === "redirect") { - router.replace(buildThreadTerminalNavigation(thread, bootstrapAction.terminalId)); - return; - } - - hasOpenedRef.current = true; - try { - const detach = attachTerminalRef.current(); - terminalDebugLog("bootstrap:subscribe", { hasDetach: Boolean(detach) }); - if (!detach) { - hasOpenedRef.current = false; - return; - } - return () => { - detach(); - hasOpenedRef.current = false; - terminalDebugLog("bootstrap:unsubscribe"); - }; - } catch (error) { - hasOpenedRef.current = false; - terminalDebugLog("bootstrap:attach-threw", { - message: error instanceof Error ? error.message : String(error), - }); - return; - } - }, [ - hasMeasuredSurface, - hasResolvedFontPreference, - requestedTerminalId, - router, - selectedThread?.environmentId, - selectedThread?.id, - selectedThreadProject?.workspaceRoot, - terminalId, - ]); - const writeInput = useCallback( (data: string) => { if (!selectedThread || !isRunning) { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - void client.terminal.write({ - threadId: selectedThread.id, - terminalId, - data, + void writeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + data, + }, }); }, - [isRunning, selectedThread, terminalId], + [isRunning, selectedThread, terminalId, writeTerminal], ); const handleInput = useCallback( @@ -782,16 +739,14 @@ export function ThreadTerminalRouteScreen() { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - void client.terminal.resize({ - threadId: selectedThread.id, - terminalId, - cols: size.cols, - rows: size.rows, + void resizeTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + cols: size.cols, + rows: size.rows, + }, }); }, [ @@ -802,6 +757,7 @@ export function ThreadTerminalRouteScreen() { readyBufferReplayKey, routeEnvironmentId, routeThreadId, + resizeTerminal, scheduleBufferReplayReady, selectedThread, terminalId, @@ -855,17 +811,15 @@ export function ThreadTerminalRouteScreen() { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - setPendingModifierState({ terminalId, value: null }); - void client.terminal.clear({ - threadId: selectedThread.id, - terminalId, + void clearTerminal({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + terminalId, + }, }); - }, [selectedThread, terminalId]); + }, [clearTerminal, selectedThread, terminalId]); const handleToolbarActionPress = useCallback( (action: TerminalToolbarAction) => { @@ -905,9 +859,14 @@ export function ThreadTerminalRouteScreen() { const handleShowKeyboard = useCallback(() => { setKeyboardFocusRequest((current) => current + 1); }, []); + const handleRetryEnvironment = useCallback(() => { + if (routeEnvironmentId !== null) { + void environmentActions.retryNow(routeEnvironmentId); + } + }, [environmentActions, routeEnvironmentId]); if (!selectedThread) { - if (isLoadingSavedConnection) { + if (workspaceState.isLoadingConnections) { return ; } @@ -932,6 +891,10 @@ export function ThreadTerminalRouteScreen() { ); } + if (!environment.isReady && environment.presentation === null) { + return ; + } + return ( <> - - - - {getTerminalStatusLabel({ - status: terminal.status, - hasRunningSubprocess: terminal.hasRunningSubprocess, - })} - - - Text size - - {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} - + {isEnvironmentReady ? ( + + + + {getTerminalStatusLabel({ + status: terminal.status, + hasRunningSubprocess: terminal.hasRunningSubprocess, + })} + + + Text size + + {`A- ${Math.max(MIN_TERMINAL_FONT_SIZE, fontSize - TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + = MAX_TERMINAL_FONT_SIZE} + discoverabilityLabel="Increase terminal text size" + onPress={handleIncreaseFontSize} + > + {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + + + {terminalMenuSessions.map((session) => ( + handleSelectTerminal(session.terminalId)} + subtitle={[ + getTerminalStatusLabel({ status: session.status }), + basename(session.cwd), + ] + .filter(Boolean) + .join(" · ")} + > + {session.displayLabel} + + ))} = MAX_TERMINAL_FONT_SIZE} - discoverabilityLabel="Increase terminal text size" - onPress={handleIncreaseFontSize} + icon="plus" + onPress={handleOpenNewTerminal} + subtitle={`Start another shell in ${basename(selectedThreadProject.workspaceRoot) ?? "this workspace"}`} > - {`A+ ${Math.min(MAX_TERMINAL_FONT_SIZE, fontSize + TERMINAL_FONT_SIZE_STEP).toFixed(1)} pt`} + Open new terminal - {terminalMenuSessions.map((session) => ( - handleSelectTerminal(session.terminalId)} - subtitle={[getTerminalStatusLabel({ status: session.status }), basename(session.cwd)] - .filter(Boolean) - .join(" · ")} - > - {session.displayLabel} - - ))} - - Open new terminal - - - + + ) : null} - - - + ) : ( + <> + + + - {isAccessoryVisible ? ( - - - - + - {terminalToolbarActions.map((action) => { - const active = - action.kind === "modifier" && pendingModifier === action.modifier; - - return ( - 1 ? 56 : 44} - onPress={() => handleToolbarActionPress(action)} - showChevron={false} - textTransform={ - action.kind === "modifier" || action.kind === "clear" - ? "uppercase" - : "none" - } - /> - ); - })} - - - - - - ) : !keyboardState.isVisible ? ( - ({ - bottom: 16, - borderRadius: 28, - opacity: pressed ? 0.72 : 1, - position: "absolute", - right: 16, - })} - > - - - - - ) : null} + + + {terminalToolbarActions.map((action) => { + const active = + action.kind === "modifier" && pendingModifier === action.modifier; + + return ( + 1 ? 56 : 44} + onPress={() => handleToolbarActionPress(action)} + showChevron={false} + textTransform={ + action.kind === "modifier" || action.kind === "clear" + ? "uppercase" + : "none" + } + /> + ); + })} + + + + + + ) : !keyboardState.isVisible ? ( + ({ + bottom: 16, + borderRadius: 28, + opacity: pressed ? 0.72 : 1, + position: "absolute", + right: 16, + })} + > + + + + + ) : null} + + )} ); diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 048ce2ac409..48c87e18dd4 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 0e0e80ef5d9..29374bdda6d 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -1,4 +1,4 @@ -import type { KnownTerminalSession } from "@t3tools/client-runtime"; +import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, type ProjectScript } from "@t3tools/contracts"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import * as Arr from "effect/Array"; diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 08746eb74e7..7e20cbf9cf3 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -6,6 +6,7 @@ import { memo } from "react"; import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { VscodeEntryIcon } from "../../components/VscodeEntryIcon"; export type ComposerCommandItem = | { @@ -88,13 +89,13 @@ function PopoverSurface(props: { function itemIcon(item: ComposerCommandItem) { switch (item.type) { - case "path": - return item.kind === "directory" ? ("folder" as const) : ("doc" as const); case "slash-command": case "provider-slash-command": return "terminal" as const; case "skill": return "cube" as const; + case "path": + return null; } } @@ -149,7 +150,11 @@ const CommandRow = memo(function CommandRow(props: { borderBottomColor: "rgba(255,255,255,0.1)", })} > - + {props.item.type === "path" ? ( + + ) : iconName ? ( + + ) : null} option.id !== id); - return { - ...selection, - options: value === undefined ? options : [...options, { id, value }], - }; -} - -function formatTitleCase(value: string): string { - return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} - function formatWorkspaceLabel(input: { readonly workspaceMode: string; readonly currentBranchName: string | null; @@ -66,7 +48,7 @@ export function NewTaskDraftScreen(props: { readonly projectId?: string; }; }) { - const { projects } = useRemoteCatalog(); + const projects = useProjects(); const { onCreateThreadWithOptions } = useProjectActions(); const flow = useNewTaskFlow(); const router = useRouter(); @@ -75,7 +57,7 @@ export function NewTaskDraftScreen(props: { const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; - const promptInputRef = useRef(null); + const promptInputRef = useRef(null); const borderColor = useThemeColor("--color-border"); const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)"; @@ -176,39 +158,18 @@ export function NewTaskDraftScreen(props: { })), [flow.providerGroups, flow.selectedModel], ); + const providerOptionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: flow.selectedModelOption?.capabilities, + selections: flow.selectedModel?.options, + }), + [flow.selectedModel?.options, flow.selectedModelOption?.capabilities], + ); const optionsMenuActions = useMemo( () => [ - { - id: "options-effort", - title: "Effort", - subtitle: `${flow.effort.charAt(0).toUpperCase()}${flow.effort.slice(1)}`, - subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ - id: `options:effort:${level}`, - title: `${level}${level === "high" ? " (default)" : ""}`, - state: flow.effort === level ? ("on" as const) : undefined, - })), - }, - { - id: "options-fast-mode", - title: "Fast Mode", - subtitle: flow.fastMode ? "On" : "Off", - subactions: ([false, true] as const).map((value) => ({ - id: `options:fast-mode:${value ? "on" : "off"}`, - title: value ? "On" : "Off", - state: flow.fastMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "options-context-window", - title: "Context Window", - subtitle: flow.contextWindow, - subactions: (["200k", "1M"] as const).map((value) => ({ - id: `options:context-window:${value}`, - title: `${value}${value === "1M" ? " (default)" : ""}`, - state: flow.contextWindow === value ? ("on" as const) : undefined, - })), - }, + ...buildProviderOptionMenuActions(providerOptionDescriptors), { id: "options-runtime", title: "Runtime", @@ -248,7 +209,7 @@ export function NewTaskDraftScreen(props: { }), }, ], - [flow.contextWindow, flow.effort, flow.fastMode, flow.interactionMode, flow.runtimeMode], + [flow.interactionMode, flow.runtimeMode, providerOptionDescriptors], ); const workspaceMenuActions = useMemo(() => { @@ -309,14 +270,10 @@ export function NewTaskDraftScreen(props: { flow.availableBranches.find((branch) => branch.current)?.name ?? flow.availableBranches.find((branch) => branch.isDefault)?.name ?? null; - const configurationLabel = useMemo(() => { - const parts = [ - formatTitleCase(flow.effort), - flow.fastMode ? "Fast" : null, - flow.contextWindow !== "1M" ? flow.contextWindow : null, - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(" · ") : "Configuration"; - }, [flow.contextWindow, flow.effort, flow.fastMode]); + const configurationLabel = useMemo( + () => providerOptionsConfigurationLabel(providerOptionDescriptors), + [providerOptionDescriptors], + ); const workspaceLabel = useMemo( () => formatWorkspaceLabel({ @@ -345,16 +302,9 @@ export function NewTaskDraftScreen(props: { } function handleOptionsMenuAction(event: string) { - if (event.startsWith("options:effort:")) { - flow.setEffort(event.slice("options:effort:".length) as typeof flow.effort); - return; - } - if (event.startsWith("options:fast-mode:")) { - flow.setFastMode(event.endsWith(":on")); - return; - } - if (event.startsWith("options:context-window:")) { - flow.setContextWindow(event.slice("options:context-window:".length)); + const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); + if (providerOptions) { + flow.setSelectedModelOptions(providerOptions); return; } if (event.startsWith("options:runtime:")) { @@ -410,10 +360,6 @@ export function NewTaskDraftScreen(props: { [flow], ); - const handleNativePaste = useNativePaste((uris) => { - void handleNativePasteImages(uris); - }); - async function handleStart(): Promise { if ( !flow.selectedProject || @@ -427,24 +373,9 @@ export function NewTaskDraftScreen(props: { flow.setSubmitting(true); try { - const modelWithOptions: ModelSelection = - flow.selectedModelOption?.providerDriver === "claudeAgent" - ? withModelSelectionOption( - withModelSelectionOption( - withModelSelectionOption(flow.selectedModel, "effort", flow.effort), - "fastMode", - flow.fastMode || undefined, - ), - "contextWindow", - flow.contextWindow, - ) - : flow.selectedModelOption?.providerDriver === "codex" - ? withModelSelectionOption(flow.selectedModel, "fastMode", flow.fastMode || undefined) - : flow.selectedModel; - const createdThread = await onCreateThreadWithOptions({ project: flow.selectedProject, - modelSelection: modelWithOptions, + modelSelection: flow.selectedModel, envMode: flow.workspaceMode, branch: flow.selectedBranchName, worktreePath: flow.workspaceMode === "worktree" ? null : flow.selectedWorktreePath, @@ -478,23 +409,19 @@ export function NewTaskDraftScreen(props: { - void handleNativePaste(payload)} + void handleNativePasteImages(uris)} + placeholder={`Describe a coding task in ${selectedProject.title}`} style={{ flex: 1, minHeight: 0 }} - > - - + textStyle={{ fontSize: 18, lineHeight: 28 }} + /> ; readonly placeholder: string; readonly bottomInset?: number; readonly connectionState: RemoteClientConnectionState; - readonly selectedThread: OrchestrationThread; + readonly connectionError: string | null; + readonly environmentLabel: string | null; + readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectCwd: string | null; + readonly editorRef?: RefObject; readonly onChangeDraftMessage: (value: string) => void; readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => Promise; - readonly onSendMessage: () => void; + readonly onSendMessage: () => Promise; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => Promise; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => Promise; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => Promise; + readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; } @@ -138,28 +137,73 @@ function ComposerSurface(props: { ); } -function withModelSelectionOption( - selection: ModelSelection, - id: string, - value: string | boolean | undefined, -): ModelSelection { - const options = (selection.options ?? []).filter((option) => option.id !== id); - return { - ...selection, - options: value === undefined ? options : [...options, { id, value }], - }; +function composerConnectionStatus(input: { + readonly connectionError: string | null; + readonly connectionState: RemoteClientConnectionState; + readonly environmentLabel: string | null; +}): { readonly kind: "unavailable" | "reconnecting"; readonly label: string } | null { + const environmentLabel = input.environmentLabel ?? "Environment"; + + switch (input.connectionState) { + case "connecting": + case "reconnecting": + return { + kind: "reconnecting", + label: + input.connectionError === null + ? `Reconnecting to ${environmentLabel}...` + : `Failed to connect. Retrying ${environmentLabel}...`, + }; + case "offline": + return { kind: "unavailable", label: "You are offline" }; + case "error": + return { + kind: "unavailable", + label: input.connectionError + ? `Failed to connect to ${environmentLabel}: ${input.connectionError}` + : `Failed to connect to ${environmentLabel}`, + }; + case "available": + return { kind: "unavailable", label: `${environmentLabel} is not connected` }; + case "connected": + return null; + } } -function formatTitleCase(value: string): string { - return value.length === 0 ? value : `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} +const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(props: { + readonly onPress: () => void; + readonly status: { readonly kind: "unavailable" | "reconnecting"; readonly label: string }; +}) { + const isReconnecting = props.status.kind === "reconnecting"; + + return ( + + + {isReconnecting ? ( + + ) : ( + + )} + + {props.status.label} + + + + ); +}); export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) { const isDarkMode = useColorScheme() === "dark"; - const themePlaceholderColor = useThemeColor("--color-placeholder"); - const placeholderColor = isDarkMode ? "#a1a1aa" : themePlaceholderColor; const foregroundColor = useThemeColor("--color-foreground"); - const inputRef = useRef(null); + const fallbackInputRef = useRef(null); + const inputRef = props.editorRef ?? fallbackInputRef; const [isFocused, setIsFocused] = useState(false); const wasExpandedBeforePreviewRef = useRef(false); const { onExpandedChange } = props; @@ -167,7 +211,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; const isExpanded = isFocused; - const canSend = props.connectionState === "ready" && hasContent; + const canSend = hasContent; const onPressImage = useCallback( (uri: string) => { @@ -182,20 +226,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (wasExpandedBeforePreviewRef.current) { setTimeout(() => inputRef.current?.focus(), 100); } - }, []); + }, [inputRef]); - useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + const handleFocus = useCallback(() => { + setIsFocused(true); + onExpandedChange?.(true); + }, [onExpandedChange]); + + const handleBlur = useCallback(() => { + setIsFocused(false); + onExpandedChange?.(false); + }, [onExpandedChange]); const showStopAction = props.selectedThread.session?.status === "running" || - props.selectedThread.session?.status === "starting" || - props.queueCount > 0; + props.selectedThread.session?.status === "starting"; - const sendLabel = props.activeThreadBusy || props.queueCount > 0 ? "Queue" : "Send"; + const sendLabel = + props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0 + ? "Queue" + : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const currentInteractionMode = props.selectedThread.interactionMode ?? "default"; + const connectionStatus = composerConnectionStatus({ + connectionError: props.connectionError, + connectionState: props.connectionState, + environmentLabel: props.environmentLabel, + }); const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; const selectedProviderStatus = useMemo(() => { @@ -207,38 +264,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); - // Extract current model options (effort, fastMode, contextWindow) - const selectedProviderDriver = selectedProviderStatus?.driver ?? null; - const currentEffort = - selectedProviderDriver === "claudeAgent" - ? (getModelSelectionStringOptionValue(currentModelSelection, "effort") ?? "high") - : "high"; - const currentFastMode = - getModelSelectionBooleanOptionValue(currentModelSelection, "fastMode") ?? false; - const currentContextWindow = - selectedProviderDriver === "claudeAgent" - ? (getModelSelectionStringOptionValue(currentModelSelection, "contextWindow") ?? "1M") - : "1M"; - - const handleNativePaste = useNativePaste((uris) => { - void props.onNativePasteImages(uris); - }); - // ── Trigger detection ──────────────────────────────────── - const [cursorPosition, setCursorPosition] = useState(0); + const [composerSelection, setComposerSelection] = useState(() => ({ + start: props.draftMessage.length, + end: props.draftMessage.length, + })); - const handleSelectionChange = useCallback( - (event: NativeSyntheticEvent) => { - const { start } = event.nativeEvent.selection; - setCursorPosition(start); - }, - [], - ); + const handleSelectionChange = useCallback((selection: ComposerEditorSelection) => { + setComposerSelection(selection); + }, []); + useEffect(() => { + const end = props.draftMessage.length; + setComposerSelection((selection) => { + const start = Math.min(selection.start, end); + const selectionEnd = Math.min(selection.end, end); + if (start === selection.start && selectionEnd === selection.end) { + return selection; + } + return { start, end: selectionEnd }; + }); + }, [props.draftMessage.length]); - const composerTrigger = useMemo( - () => detectComposerTrigger(props.draftMessage, cursorPosition), - [cursorPosition, props.draftMessage], - ); + const composerTrigger = useMemo(() => { + if (composerSelection.start !== composerSelection.end) { + return null; + } + return detectComposerTrigger(props.draftMessage, composerSelection.end); + }, [composerSelection, props.draftMessage]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, @@ -394,8 +446,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(() => { - onSendMessage(); - inputRef.current?.blur(); + void onSendMessage().then(() => { + inputRef.current?.blur(); + }); }, [onSendMessage]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { @@ -411,7 +464,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, "", ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); void onUpdateInteractionMode(item.command); return; @@ -434,7 +487,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer composerTrigger.rangeEnd, replacement, ); - setCursorPosition(result.cursor); + setComposerSelection({ start: result.cursor, end: result.cursor }); onChangeDraftMessage(result.text); }, [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], @@ -452,14 +505,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer option.selection.instanceId === currentModelSelection.instanceId && option.selection.model === currentModelSelection.model, ) ?? null; - const configurationLabel = useMemo(() => { - const parts = [ - formatTitleCase(currentEffort), - currentFastMode ? "Fast" : null, - currentContextWindow !== "1M" ? currentContextWindow : null, - ].filter((part): part is string => Boolean(part)); - return parts.length > 0 ? parts.join(" · ") : "Configuration"; - }, [currentContextWindow, currentEffort, currentFastMode]); + const providerOptionDescriptors = useMemo( + () => + resolveProviderOptionDescriptors({ + capabilities: currentModelOption?.capabilities, + selections: currentModelSelection.options, + }), + [currentModelOption?.capabilities, currentModelSelection.options], + ); + const configurationLabel = useMemo( + () => providerOptionsConfigurationLabel(providerOptionDescriptors), + [providerOptionDescriptors], + ); const modelMenuActions = useMemo( () => providerGroups.map((group) => ({ @@ -486,36 +543,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // ── Options menu ───────────────────────────────────────── const optionsMenuActions = useMemo( () => [ - { - id: "options-effort", - title: "Effort", - subtitle: `${currentEffort.charAt(0).toUpperCase()}${currentEffort.slice(1)}`, - subactions: CLAUDE_AGENT_EFFORT_OPTIONS.map((level) => ({ - id: `options:effort:${level}`, - title: `${level}${level === "high" ? " (default)" : ""}`, - state: currentEffort === level ? ("on" as const) : undefined, - })), - }, - { - id: "options-fast-mode", - title: "Fast Mode", - subtitle: currentFastMode ? "On" : "Off", - subactions: ([false, true] as const).map((value) => ({ - id: `options:fast-mode:${value ? "on" : "off"}`, - title: value ? "On" : "Off", - state: currentFastMode === value ? ("on" as const) : undefined, - })), - }, - { - id: "options-context-window", - title: "Context Window", - subtitle: currentContextWindow, - subactions: (["200k", "1M"] as const).map((value) => ({ - id: `options:context-window:${value}`, - title: `${value}${value === "1M" ? " (default)" : ""}`, - state: currentContextWindow === value ? ("on" as const) : undefined, - })), - }, + ...buildProviderOptionMenuActions(providerOptionDescriptors), { id: "options-runtime", title: "Runtime", @@ -555,13 +583,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }), }, ], - [ - currentEffort, - currentFastMode, - currentContextWindow, - currentRuntimeMode, - currentInteractionMode, - ], + [currentInteractionMode, currentRuntimeMode, providerOptionDescriptors], ); // ── Menu handlers ──────────────────────────────────────── @@ -577,36 +599,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } function handleOptionsMenuAction(event: string) { - if (event.startsWith("options:effort:")) { - const effort = event.slice("options:effort:".length); - const updated: ModelSelection = - selectedProviderDriver === "claudeAgent" - ? withModelSelectionOption( - currentModelSelection, - "effort", - effort as typeof currentEffort, - ) - : currentModelSelection; - void props.onUpdateModelSelection(updated); - return; - } - if (event.startsWith("options:fast-mode:")) { - const fastMode = event.endsWith(":on"); - const nextFast = fastMode || undefined; - if (selectedProviderDriver === "opencode") { - return; - } - const updated = withModelSelectionOption(currentModelSelection, "fastMode", nextFast); - void props.onUpdateModelSelection(updated); - return; - } - if (event.startsWith("options:context-window:")) { - const contextWindow = event.slice("options:context-window:".length); - const updated: ModelSelection = - selectedProviderDriver === "claudeAgent" - ? withModelSelectionOption(currentModelSelection, "contextWindow", contextWindow) - : currentModelSelection; - void props.onUpdateModelSelection(updated); + const providerOptions = applyProviderOptionMenuEvent(providerOptionDescriptors, event); + if (providerOptions) { + void props.onUpdateModelSelection({ + ...currentModelSelection, + options: providerOptions, + }); return; } if (event.startsWith("options:runtime:")) { @@ -624,8 +622,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) : null} + {connectionStatus ? ( + + ) : null} + - - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textAlignVertical={isExpanded ? "top" : "center"} - style={ - isExpanded - ? { - minHeight: 80, - maxHeight: 160, - paddingHorizontal: 4, - paddingVertical: 4, - fontSize: 15, - lineHeight: 22, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - : { - maxHeight: 36, - paddingVertical: 6, - fontSize: 15, - lineHeight: 20, - color: foregroundColor, - fontFamily: "DMSans_400Regular", - } - } - /> - + void props.onNativePasteImages(uris)} + placeholder={props.placeholder} + onFocus={handleFocus} + onBlur={handleBlur} + scrollEnabled={isExpanded} + contentInsetVertical={isExpanded ? 0 : 6} + style={ + isExpanded + ? { + minHeight: 80, + maxHeight: 160, + paddingHorizontal: 4, + paddingVertical: 4, + } + : { + height: 36, + } + } + textStyle={{ + fontSize: 15, + lineHeight: isExpanded ? 22 : 20, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + }} + /> {!isExpanded && props.draftAttachments.length > 0 ? ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8e6050418fd..7cfe1d1744e 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,8 +1,9 @@ +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import type { ApprovalRequestId, EnvironmentId, ModelSelection, - OrchestrationThread, + OrchestrationThreadShell, ProviderApprovalDecision, ProviderInteractionMode, RuntimeMode, @@ -11,17 +12,20 @@ import type { } from "@t3tools/contracts"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; +import { useHeaderHeight } from "expo-router/build/react-navigation/elements"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { View, type LayoutChangeEvent } from "react-native"; +import { View, type GestureResponderEvent, type LayoutChangeEvent } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { KeyboardStickyView } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { runOnJS } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import type { MobileLayoutVariant } from "../../lib/mobileLayout"; +import type { LayoutVariant } from "../../lib/layout"; +import { resolveThreadFeedBottomInset } from "../../lib/threadFeedLayout"; import type { PendingApproval, PendingUserInput, @@ -33,17 +37,20 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, - COMPOSER_EXPANDED_TOOLBAR_CHROME, ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; +import type { ThreadContentPresentation } from "./threadContentPresentation"; export interface ThreadDetailScreenProps { - readonly selectedThread: OrchestrationThread; + readonly selectedThread: OrchestrationThreadShell; + readonly contentPresentation: ThreadContentPresentation; readonly screenTone: StatusTone; readonly connectionError: string | null; + readonly environmentLabel: string | null; readonly httpBaseUrl: string | null; readonly bearerToken: string | null; + readonly dpopAccessToken?: string; readonly selectedThreadFeed: ReadonlyArray; readonly activeWorkStartedAt: string | null; readonly activePendingApproval: PendingApproval | null; @@ -54,13 +61,13 @@ export interface ThreadDetailScreenProps { readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; - readonly connectionStateLabel: "ready" | "connecting" | "reconnecting" | "disconnected" | "idle"; + readonly connectionStateLabel: EnvironmentConnectionPhase; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; readonly selectedThreadQueueCount: number; readonly serverConfig: T3ServerConfig | null; - readonly layoutVariant?: MobileLayoutVariant; + readonly layoutVariant?: LayoutVariant; readonly onOpenDrawer: () => void; readonly onOpenConnectionEditor: () => void; readonly onChangeDraftMessage: (value: string) => void; @@ -68,7 +75,8 @@ export interface ThreadDetailScreenProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => Promise; - readonly onSendMessage: () => void; + readonly onSendMessage: () => Promise; + readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => Promise; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => Promise; readonly onUpdateThreadInteractionMode: ( @@ -200,7 +208,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const { onOpenDrawer } = props; const insets = useSafeAreaInsets(); + const headerHeight = useHeaderHeight(); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; + const composerRef = useRef(null); + const feedTouchStartRef = useRef<{ pageX: number; pageY: number } | null>(null); const [composerExpanded, setComposerExpanded] = useState(false); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; @@ -211,10 +222,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; + const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const expandedToolbarInset = composerExpanded ? COMPOSER_EXPANDED_TOOLBAR_CHROME : 0; - const feedBottomInset = - Math.max(estimatedOverlayHeight, measuredOverlayHeight) + expandedToolbarInset + 8; + const feedBottomInset = resolveThreadFeedBottomInset({ + estimatedOverlayHeight, + measuredOverlayHeight, + gap: 8, + }); + const selectedProviderSkills = useMemo( + () => + props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) + ?.skills ?? [], + [props.serverConfig, selectedInstanceId], + ); const completeDrawerGesture = useCallback(() => { void Haptics.selectionAsync(); @@ -245,20 +265,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ); }, []); + const collapseComposer = useCallback(() => { + composerRef.current?.blur(); + }, []); + + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { + feedTouchStartRef.current = { + pageX: event.nativeEvent.pageX, + pageY: event.nativeEvent.pageY, + }; + }, []); + + const handleFeedTouchMove = useCallback((event: GestureResponderEvent) => { + const start = feedTouchStartRef.current; + if (!start) { + return; + } + const deltaX = event.nativeEvent.pageX - start.pageX; + const deltaY = event.nativeEvent.pageY - start.pageY; + if (Math.hypot(deltaX, deltaY) > 8) { + feedTouchStartRef.current = null; + } + }, []); + + const handleFeedTouchEnd = useCallback(() => { + if (feedTouchStartRef.current) { + collapseComposer(); + } + feedTouchStartRef.current = null; + }, [collapseComposer]); + + const handleFeedTouchCancel = useCallback(() => { + feedTouchStartRef.current = null; + }, []); + return ( {showContent ? ( - + + + ) : ( )} @@ -298,10 +366,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} ; + readonly contentPresentation: ThreadContentPresentation; readonly httpBaseUrl: string | null; readonly bearerToken: string | null; + readonly dpopAccessToken?: string; readonly agentLabel: string; + readonly latestTurn: ThreadFeedLatestTurn | null; + readonly contentTopInset?: number; readonly contentBottomInset?: number; - readonly layoutVariant?: MobileLayoutVariant; + readonly layoutVariant?: LayoutVariant; readonly composerExpanded?: boolean; + readonly skills?: ReadonlyArray; +} + +function MessageAttachmentImage(props: { + readonly uri: string; + readonly bearerToken: string | null; + readonly dpopAccessToken?: string; + readonly className: string; + readonly onPressImage: (uri: string, headers?: Record) => void; +}) { + const request = useRemoteHttpHeaders({ + url: props.uri, + bearerToken: props.bearerToken, + ...(props.dpopAccessToken ? { dpopAccessToken: props.dpopAccessToken } : {}), + }); + + if (!request.isReady) { + return ( + + + + ); + } + + const headers = request.headers ?? undefined; + return ( + props.onPressImage(props.uri, headers)}> + + + ); } function stripShellWrapper(value: string): string { @@ -75,34 +146,48 @@ function compactActivityDetail(detail: string | null): string | null { } function buildActivityRows( - activities: ReadonlyArray<{ - readonly id: string; - readonly createdAt: string; - readonly summary: string; - readonly detail: string | null; - readonly status: string | null; - }>, + activities: Extract["activities"], ) { - return activities.map<{ - id: string; - createdAt: string; - summary: string; - detail: string | null; - status: string | null; - }>((activity) => ({ - id: activity.id, - createdAt: activity.createdAt, - summary: activity.summary, + return activities.map((activity) => ({ + ...activity, detail: compactActivityDetail(activity.detail), - status: activity.status, })); } -const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; +const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; -function toMarkdownThemeColor(value: ColorValue): string { - return value as string; -} +const MARKDOWN_COLORS = { + light: { + body: "#111111", + strong: "#000000", + link: "#2563eb", + blockquoteBorder: "rgba(0, 0, 0, 0.08)", + blockquoteBackground: "rgba(0, 0, 0, 0.02)", + codeBackground: "rgba(0, 0, 0, 0.04)", + codeText: "#262626", + horizontalRule: "rgba(0, 0, 0, 0.08)", + userBody: "#ffffff", + userCodeBackground: "rgba(255, 255, 255, 0.22)", + userCodeText: "#ffffff", + userFenceBackground: "rgba(0, 0, 0, 0.16)", + userFenceText: "#ffffff", + }, + dark: { + body: "#e5e5e5", + strong: "#f5f5f5", + link: "#60a5fa", + blockquoteBorder: "rgba(255, 255, 255, 0.1)", + blockquoteBackground: "rgba(255, 255, 255, 0.03)", + codeBackground: "rgba(255, 255, 255, 0.06)", + codeText: "#e5e5e5", + horizontalRule: "rgba(255, 255, 255, 0.08)", + userBody: "#ffffff", + userCodeBackground: "rgba(255, 255, 255, 0.18)", + userCodeText: "#ffffff", + userFenceBackground: "rgba(0, 0, 0, 0.28)", + userFenceText: "#ffffff", + }, +} as const; interface MarkdownStyleSets { readonly user: MarkdownStyleSet; @@ -113,6 +198,7 @@ interface MarkdownStyleSet { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; readonly renderers: CustomRenderers; + readonly nativeTextStyle: NativeMarkdownTextStyle; } interface ReviewCommentColors { @@ -124,6 +210,70 @@ interface ReviewCommentColors { readonly codeBackground: ColorValue; } +const failedMarkdownFaviconHosts = new Set(); +const markdownLinkStyles = StyleSheet.create({ + favicon: { + width: 14, + height: 14, + borderRadius: 3, + marginHorizontal: 3, + transform: [{ translateY: 2 }], + }, + file: { + borderRadius: 5, + borderWidth: StyleSheet.hairlineWidth, + fontFamily: "DMSans_500Medium", + fontSize: 13, + lineHeight: 20, + paddingHorizontal: 6, + paddingVertical: 2, + }, + fileIcon: { + width: 15, + height: 15, + marginRight: 4, + transform: [{ translateY: 2 }], + }, +}); + +const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { + readonly children: ReactNode; + readonly color: string; + readonly host: string; + readonly href: string; +}) { + const [failed, setFailed] = useState(() => failedMarkdownFaviconHosts.has(props.host)); + + return ( + { + void Linking.openURL(props.href); + }} + style={{ + color: props.color, + fontFamily: "DMSans_400Regular", + textDecorationLine: "none", + }} + > + {!failed ? ( + { + failedMarkdownFaviconHosts.add(props.host); + setFailed(true); + }} + /> + ) : ( + {" ◉ "} + )} + {props.children} + + ); +}); + function useReviewCommentColors(): ReviewCommentColors { const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; @@ -148,34 +298,26 @@ function useReviewCommentColors(): ReviewCommentColors { } function useMarkdownStyles(): MarkdownStyleSets { - const bodyColor = useThemeColor("--color-md-body"); - const strongColor = useThemeColor("--color-md-strong"); - const linkColor = useThemeColor("--color-md-link"); - const blockquoteBg = useThemeColor("--color-md-blockquote-bg"); - const blockquoteBorder = useThemeColor("--color-md-blockquote-border"); - const codeBg = useThemeColor("--color-md-code-bg"); - const codeText = useThemeColor("--color-md-code-text"); - const hrColor = useThemeColor("--color-md-hr"); - const userBodyColor = useThemeColor("--color-user-bubble-foreground"); - const userCodeBg = useThemeColor("--color-md-user-code-bg"); - const userCodeText = useThemeColor("--color-md-user-code-text"); - const userFenceBg = useThemeColor("--color-md-user-fence-bg"); - const userFenceText = useThemeColor("--color-md-user-fence-text"); + const colorScheme = useColorScheme(); + const colors = MARKDOWN_COLORS[colorScheme === "dark" ? "dark" : "light"]; + const inlineChipBackground = String(useThemeColor("--color-subtle")); + const inlineSkillBackground = String(useThemeColor("--color-inline-skill-background")); + const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground")); return useMemo(() => { - const markdownBodyColor = toMarkdownThemeColor(bodyColor); - const markdownStrongColor = toMarkdownThemeColor(strongColor); - const markdownLinkColor = toMarkdownThemeColor(linkColor); - const markdownBlockquoteBg = toMarkdownThemeColor(blockquoteBg); - const markdownBlockquoteBorder = toMarkdownThemeColor(blockquoteBorder); - const markdownCodeBg = toMarkdownThemeColor(codeBg); - const markdownCodeText = toMarkdownThemeColor(codeText); - const markdownHrColor = toMarkdownThemeColor(hrColor); - const markdownUserBodyColor = toMarkdownThemeColor(userBodyColor); - const markdownUserCodeBg = toMarkdownThemeColor(userCodeBg); - const markdownUserCodeText = toMarkdownThemeColor(userCodeText); - const markdownUserFenceBg = toMarkdownThemeColor(userFenceBg); - const markdownUserFenceText = toMarkdownThemeColor(userFenceText); + const markdownBodyColor = colors.body; + const markdownStrongColor = colors.strong; + const markdownLinkColor = colors.link; + const markdownBlockquoteBg = colors.blockquoteBackground; + const markdownBlockquoteBorder = colors.blockquoteBorder; + const markdownCodeBg = colors.codeBackground; + const markdownCodeText = colors.codeText; + const markdownHrColor = colors.horizontalRule; + const markdownUserBodyColor = colors.userBody; + const markdownUserCodeBg = colors.userCodeBackground; + const markdownUserCodeText = colors.userCodeText; + const markdownUserFenceBg = colors.userFenceBackground; + const markdownUserFenceText = colors.userFenceText; const baseTheme: PartialMarkdownTheme = { colors: { @@ -202,12 +344,12 @@ function useMarkdownStyles(): MarkdownStyleSets { fontSizes: { s: 13, m: 15, - h1: 22, - h2: 19, - h3: 17, - h4: 15, - h5: 15, - h6: 15, + h1: 20, + h2: 18, + h3: 16, + h4: 14, + h5: 14, + h6: 14, }, fontFamilies: { regular: "DMSans_400Regular", @@ -225,8 +367,8 @@ function useMarkdownStyles(): MarkdownStyleSets { const baseStyles: NodeStyleOverrides = { document: { flexShrink: 1 }, - paragraph: { marginTop: 0, marginBottom: 8 }, - list: { marginTop: 4, marginBottom: 4 }, + paragraph: { marginTop: 0, marginBottom: 10 }, + list: { marginTop: 4, marginBottom: 8 }, list_item: { marginTop: 0, marginBottom: 4 }, task_list_item: { marginTop: 0, marginBottom: 4 }, text: { lineHeight: 22 }, @@ -241,20 +383,18 @@ function useMarkdownStyles(): MarkdownStyleSets { textDecorationLine: "underline" as const, }, blockquote: { - borderLeftWidth: 3, + borderLeftWidth: 2, borderLeftColor: markdownBlockquoteBorder, - backgroundColor: markdownBlockquoteBg, - paddingLeft: 12, - paddingVertical: 6, + paddingLeft: 11, + paddingVertical: 2, marginLeft: 0, - marginVertical: 4, - borderRadius: 4, + marginVertical: 10, }, heading: { fontFamily: "DMSans_700Bold", color: markdownStrongColor, - marginTop: 12, - marginBottom: 6, + marginTop: 18, + marginBottom: 8, }, horizontal_rule: { backgroundColor: markdownHrColor, @@ -263,44 +403,173 @@ function useMarkdownStyles(): MarkdownStyleSets { }, }; - const createCodeRenderers = ( + const createMarkdownRenderers = ( inlineBackgroundColor: string, inlineTextColor: string, blockBackgroundColor: string, blockTextColor: string, ): CustomRenderers => ({ - code_inline: ({ content }) => ( - - {content} - + link: ({ children, href = "" }) => { + const presentation = resolveMarkdownLinkPresentation(href); + if (presentation.kind === "file") { + return ( + + + {presentation.label} + + ); + } + if (presentation.kind === "external") { + return ( + + {children} + + ); + } + const linkHref = presentation.href; + return ( + { + void Linking.openURL(linkHref); + } + : undefined + } + style={{ + color: markdownLinkColor, + textDecorationLine: "underline", + }} + > + {children} + + ); + }, + list: ({ node, Renderer, ordered = false, start = 1 }) => ( + + {node.children?.map((child, index) => { + const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; + if (child.type === "task_list_item") { + return ( + + ); + } + return ( + + + {ordered ? `${start + index}.` : "•"} + + + + + + ); + })} + ), - code_block: ({ content }) => ( + code_inline: ({ content }) => { + const value = content ?? ""; + const wrapsPoorly = + value.length > 24 || value.includes("/") || value.includes("\\") || value.includes(":"); + return ( + + {value} + + ); + }, + code_block: ({ content, language }) => ( - + {language ? ( + + + {language} + + + ) : null} + {content} @@ -333,6 +602,8 @@ function useMarkdownStyles(): MarkdownStyleSets { heading: { ...baseStyles.heading, color: markdownUserBodyColor, + marginTop: 8, + marginBottom: 4, }, link: { color: markdownUserBodyColor, @@ -357,48 +628,79 @@ function useMarkdownStyles(): MarkdownStyleSets { user: { theme: userTheme, styles: userStyles, - renderers: createCodeRenderers( + renderers: createMarkdownRenderers( markdownUserCodeBg, markdownUserCodeText, markdownUserFenceBg, markdownUserFenceText, ), + nativeTextStyle: { + color: markdownUserBodyColor, + strongColor: markdownUserBodyColor, + mutedColor: markdownUserBodyColor, + linkColor: markdownUserBodyColor, + codeColor: markdownUserCodeText, + codeBackgroundColor: markdownUserCodeBg, + codeBlockBackgroundColor: markdownUserFenceBg, + fileBackgroundColor: "rgba(255, 255, 255, 0.12)", + fileTextColor: "#ffffff", + skillBackgroundColor: "rgba(217, 70, 239, 0.24)", + skillTextColor: "#ffffff", + quoteMarkerColor: markdownUserBodyColor, + dividerColor: markdownUserBodyColor, + fontSize: 15, + lineHeight: 22, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, assistant: { theme: assistantTheme, styles: assistantStyles, - renderers: createCodeRenderers( + renderers: createMarkdownRenderers( markdownCodeBg, markdownCodeText, markdownCodeBg, markdownCodeText, ), + nativeTextStyle: { + color: markdownBodyColor, + strongColor: markdownStrongColor, + mutedColor: markdownBodyColor, + linkColor: markdownLinkColor, + codeColor: markdownCodeText, + codeBackgroundColor: markdownCodeBg, + codeBlockBackgroundColor: markdownCodeBg, + fileBackgroundColor: inlineChipBackground, + fileTextColor: markdownCodeText, + skillBackgroundColor: inlineSkillBackground, + skillTextColor: inlineSkillForeground, + quoteMarkerColor: markdownBlockquoteBorder, + dividerColor: markdownHrColor, + fontSize: 15, + lineHeight: 22, + fontFamily: "DMSans_400Regular", + headingFontFamily: "DMSans_700Bold", + boldFontFamily: "DMSans_700Bold", + }, }, }; - }, [ - blockquoteBg, - blockquoteBorder, - bodyColor, - codeBg, - codeText, - hrColor, - linkColor, - strongColor, - userBodyColor, - userCodeBg, - userCodeText, - userFenceBg, - userFenceText, - ]); + }, [colors, inlineChipBackground, inlineSkillBackground, inlineSkillForeground]); } function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, - props: Pick & { + props: Pick & { readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly terminalAssistantMessageIds: ReadonlySet; + readonly unsettledTurnId: TurnId | null; readonly onCopyWorkRow: (rowId: string, value: string) => void; readonly onToggleWorkGroup: (groupId: string) => void; + readonly onToggleWorkRow: (rowId: string) => void; + readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; @@ -410,19 +712,50 @@ function renderFeedEntry( const entry = info.item; const { markdownStyles, iconSubtleColor, userBubbleColor } = props; + if (entry.type === "turn-fold") { + return ( + props.onToggleTurnFold(entry.turnId)} + hitSlop={4} + className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-neutral-200/80 px-2 dark:border-white/[0.08]" + > + + {entry.label} + + + + ); + } + if (entry.type === "message") { const { message } = entry; const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; - const timestampLabel = `${relativeTime(message.createdAt)}${message.streaming ? " • live" : ""}`; + const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = message.text.includes(" ) : null} {attachments.map((attachment) => { @@ -440,28 +774,32 @@ function renderFeedEntry( if (!uri) { return null; } - const headers = props.bearerToken - ? { Authorization: `Bearer ${props.bearerToken}` } - : undefined; - return ( - props.onPressImage(uri, headers)} - > - - + uri={uri} + bearerToken={props.bearerToken} + dpopAccessToken={props.dpopAccessToken} + className="aspect-[1.3] w-full rounded-[14px] bg-white/15" + onPressImage={props.onPressImage} + /> ); })} - - {timestampLabel} - + + + {timestampLabel} + + {message.text.trim().length > 0 ? ( + + ) : null} + ); } @@ -473,44 +811,55 @@ function renderFeedEntry( } return ( - + {message.text.trim().length > 0 ? ( - - {message.text} - + hasNativeSelectableMarkdownText() ? ( + + ) : ( + + {message.text} + + ) ) : null} {attachments.map((attachment) => { const uri = messageImageUrl(props.httpBaseUrl, attachment.id); if (!uri) { return null; } - const headers = props.bearerToken - ? { Authorization: `Bearer ${props.bearerToken}` } - : undefined; - return ( - props.onPressImage(uri, headers)} - > - - + uri={uri} + bearerToken={props.bearerToken} + dpopAccessToken={props.dpopAccessToken} + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800" + onPressImage={props.onPressImage} + /> ); })} - - {timestampLabel} - + {showAssistantMeta ? ( + + + + {timestampLabel} + + + ) : null} ); } @@ -539,67 +888,121 @@ function renderFeedEntry( ); } - const rows = buildActivityRows(entry.activities); + const rows = buildActivityRows(entry.activities).filter( + (activity) => !(activity.toolLike && activity.status === "neutral"), + ); + if (rows.length === 0) { + return null; + } const isExpanded = props.expandedWorkGroups[entry.id] ?? false; const hasOverflow = rows.length > MAX_VISIBLE_WORK_LOG_ENTRIES; const visibleRows = hasOverflow && !isExpanded ? rows.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) : rows; const hiddenCount = rows.length - visibleRows.length; - const showHeader = hasOverflow; + const onlyToolRows = rows.every((row) => row.toolLike); + const headerTitle = onlyToolRows + ? rows.length === 1 + ? "1 tool call" + : `${rows.length} tool calls` + : "Work log"; return ( - - {showHeader ? ( - - - Tool calls ({rows.length}) - - props.onToggleWorkGroup(entry.id)}> - + + + {headerTitle} + {hasOverflow ? ( + props.onToggleWorkGroup(entry.id)} + className="flex-row items-center gap-1" + > + {isExpanded ? "Show less" : `Show ${hiddenCount} more`} + - - ) : null} + ) : null} + {visibleRows.map((row, index) => ( - { + if (row.fullDetail) { + props.onToggleWorkRow(row.id); + } + }} + onLongPress={() => props.onCopyWorkRow(row.id, row.copyText)} className={cn( - "flex-row items-center gap-2 rounded-lg px-1 py-1", + "rounded-lg px-2 py-1.5", index > 0 && "border-t border-neutral-200/80 dark:border-white/[0.06]", )} > - - - - + + + + { - const copyValue = row.detail ?? row.summary; - props.onCopyWorkRow(row.id, copyValue); - }} - style={{ - fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", - }} + className="min-w-0 flex-1 text-[12px] leading-[18px] text-neutral-700 dark:text-neutral-300" + numberOfLines={props.expandedWorkRows[row.id] ? undefined : 1} > {row.detail ? `${row.summary} - ${row.detail}` : row.summary} - - {props.copiedRowId === row.id ? ( - - Copied - + {row.fullDetail ? ( + + ) : null} + {props.copiedRowId === row.id ? ( + + Copied + + ) : null} + + {row.fullDetail && props.expandedWorkRows[row.id] ? ( + + + {row.fullDetail} + + ) : null} - + ))} ); @@ -609,10 +1012,20 @@ function UserMessageContent(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; readonly reviewCommentColors: ReviewCommentColors; + readonly skills?: ReadonlyArray; }) { const segments = parseReviewCommentMessageSegments(props.text); const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); if (!hasReviewComment) { + if (hasNativeSelectableMarkdownText()) { + return ( + + ); + } return ( + ) : ( = 0 ? normalized.slice(lastSlashIndex + 1) : normalized; } -const IOS_NAV_BAR_HEIGHT = 44; +function ThreadFeedPlaceholder(props: { + readonly bottomInset: number; + readonly detail: string; + readonly horizontalPadding: number; + readonly loading?: boolean; + readonly title: string; + readonly topInset: number; +}) { + return ( + + + {props.loading ? : null} + {props.title} + + {props.detail} + + + + ); +} export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const listRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>(null); + const scrollFrameRef = useRef(null); + const foldSettleFrameRef = useRef(null); + const foldSettleSecondFrameRef = useRef(null); + const suppressAutoFollowRef = useRef(false); + const previousLatestTurnRef = useRef(props.latestTurn); + const isNearEndRef = useRef(true); + const initialScrollReadyRef = useRef(false); + const lastContentHeightRef = useRef(0); const { width: viewportWidth } = useWindowDimensions(); - const [copiedRowId, setCopiedRowId] = useState(null); - const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); + const [interactionState, setInteractionState] = useState<{ + readonly copiedRowId: string | null; + readonly expandedWorkGroups: Record; + readonly expandedWorkRows: Record; + readonly expandedTurnIds: ReadonlySet; + }>({ + copiedRowId: null, + expandedWorkGroups: {}, + expandedWorkRows: {}, + expandedTurnIds: new Set(), + }); + const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedImage, setExpandedImage] = useState<{ uri: string; headers?: Record; @@ -835,47 +1302,193 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const contentWidth = Math.max(0, viewportWidth - horizontalPadding * 2); const reviewCommentBubbleWidth = Math.min(Math.max(280, contentWidth * 0.85), contentWidth); const insets = useSafeAreaInsets(); - const topContentInset = insets.top + IOS_NAV_BAR_HEIGHT; + const topContentInset = props.contentTopInset ?? insets.top + 44; const bottomContentInset = props.contentBottomInset ?? 18; const iconSubtleColor = useThemeColor("--color-icon-subtle"); const userBubbleColor = useThemeColor("--color-user-bubble"); const markdownStyles = useMarkdownStyles(); const reviewCommentColors = useReviewCommentColors(); + const listAppearanceData = useMemo( + () => ({ + iconSubtleColor, + markdownStyles, + reviewCommentColors, + userBubbleColor, + }), + [iconSubtleColor, markdownStyles, reviewCommentColors, userBubbleColor], + ); + const presentedFeed = useMemo( + () => deriveThreadFeedPresentation(props.feed, props.latestTurn, expandedTurnIds), + [expandedTurnIds, props.feed, props.latestTurn], + ); + const terminalAssistantMessageIds = useMemo(() => { + const terminalIdsByTurn = new Map(); + for (const entry of props.feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalIdsByTurn.set(entry.message.turnId, entry.message.id); + } + } + return new Set(terminalIdsByTurn.values()); + }, [props.feed]); + const unsettledTurnId = + props.latestTurn && + (props.latestTurn.completedAt === null || props.latestTurn.state === "running") + ? props.latestTurn.turnId + : null; + + const scrollToEnd = useCallback(() => { + if (scrollFrameRef.current !== null) { + return; + } + scrollFrameRef.current = requestAnimationFrame(() => { + scrollFrameRef.current = null; + listRef.current?.scrollToEnd({ animated: false }); + }); + }, []); + + const onListScroll = useCallback( + (event: NativeSyntheticEvent | NativeScrollEvent) => { + const scrollEvent = "nativeEvent" in event ? event.nativeEvent : event; + const { contentInset, contentOffset, contentSize, layoutMeasurement } = scrollEvent; + isNearEndRef.current = isThreadFeedNearEnd( + { + contentHeight: contentSize.height, + viewportHeight: layoutMeasurement.height, + offsetY: contentOffset.y, + bottomInset: contentInset.bottom, + }, + THREAD_FEED_END_THRESHOLD, + ); + }, + [], + ); + + const onListContentSizeChange = useCallback( + (_width: number, height: number) => { + const contentGrew = height > lastContentHeightRef.current + 0.5; + lastContentHeightRef.current = height; + + if ( + initialScrollReadyRef.current && + contentGrew && + isNearEndRef.current && + !suppressAutoFollowRef.current + ) { + scrollToEnd(); + } + }, + [scrollToEnd], + ); + + const onListLoad = useCallback(() => { + initialScrollReadyRef.current = true; + }, []); useEffect(() => { - setCopiedRowId(null); - setExpandedWorkGroups({}); - }, [props.threadId]); + const previous = previousLatestTurnRef.current; + previousLatestTurnRef.current = props.latestTurn; + if (!props.latestTurn || !previous) { + return; + } + if (props.latestTurn.turnId === previous.turnId) { + if (previous.state === "running" && props.latestTurn.state === "interrupted") { + const interruptedTurnId = props.latestTurn.turnId; + setInteractionState((current) => ({ + ...current, + expandedTurnIds: new Set(current.expandedTurnIds).add(interruptedTurnId), + })); + } + return; + } + setInteractionState((current) => { + if (!current.expandedTurnIds.has(previous.turnId)) { + return current; + } + const next = new Set(current.expandedTurnIds); + next.delete(previous.turnId); + return { ...current, expandedTurnIds: next }; + }); + }, [props.latestTurn]); useEffect(() => { return () => { if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } + if (scrollFrameRef.current !== null) { + cancelAnimationFrame(scrollFrameRef.current); + } + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } }; }, []); const onCopyWorkRow = useCallback((rowId: string, value: string) => { void Clipboard.setStringAsync(value); void Haptics.selectionAsync(); - setCopiedRowId(rowId); + setInteractionState((current) => ({ ...current, copiedRowId: rowId })); if (copyFeedbackTimeoutRef.current) { clearTimeout(copyFeedbackTimeoutRef.current); } copyFeedbackTimeoutRef.current = setTimeout(() => { - setCopiedRowId((current) => (current === rowId ? null : current)); + setInteractionState((current) => + current.copiedRowId === rowId ? { ...current, copiedRowId: null } : current, + ); copyFeedbackTimeoutRef.current = null; }, 1200); }, []); const onToggleWorkGroup = useCallback((groupId: string) => { - setExpandedWorkGroups((current) => ({ + setInteractionState((current) => ({ + ...current, + expandedWorkGroups: { + ...current.expandedWorkGroups, + [groupId]: !(current.expandedWorkGroups[groupId] ?? false), + }, + })); + }, []); + + const onToggleWorkRow = useCallback((rowId: string) => { + setInteractionState((current) => ({ ...current, - [groupId]: !(current[groupId] ?? false), + expandedWorkRows: { + ...current.expandedWorkRows, + [rowId]: !(current.expandedWorkRows[rowId] ?? false), + }, })); }, []); + const onToggleTurnFold = useCallback((turnId: TurnId) => { + suppressAutoFollowRef.current = true; + if (foldSettleFrameRef.current !== null) { + cancelAnimationFrame(foldSettleFrameRef.current); + } + if (foldSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(foldSettleSecondFrameRef.current); + } + setInteractionState((current) => { + const next = new Set(current.expandedTurnIds); + if (next.has(turnId)) { + next.delete(turnId); + } else { + next.add(turnId); + } + return { ...current, expandedTurnIds: next }; + }); + foldSettleFrameRef.current = requestAnimationFrame(() => { + foldSettleSecondFrameRef.current = requestAnimationFrame(() => { + suppressAutoFollowRef.current = false; + foldSettleFrameRef.current = null; + foldSettleSecondFrameRef.current = null; + }); + }); + }, []); + const onPressImage = useCallback((uri: string, headers?: Record) => { setExpandedImage({ uri, headers }); }, []); @@ -884,21 +1497,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { (info: { item: ThreadFeedEntry; index: number }) => renderFeedEntry(info, { bearerToken: props.bearerToken, + dpopAccessToken: props.dpopAccessToken, copiedRowId, httpBaseUrl: props.httpBaseUrl, expandedWorkGroups, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, onCopyWorkRow, onToggleWorkGroup, + onToggleWorkRow, + onToggleTurnFold, onPressImage, iconSubtleColor, userBubbleColor, markdownStyles, reviewCommentColors, reviewCommentBubbleWidth, + skills: props.skills, }), [ copiedRowId, expandedWorkGroups, + expandedWorkRows, + terminalAssistantMessageIds, + unsettledTurnId, iconSubtleColor, userBubbleColor, markdownStyles, @@ -906,62 +1529,95 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentBubbleWidth, onCopyWorkRow, onPressImage, + onToggleTurnFold, onToggleWorkGroup, + onToggleWorkRow, props.bearerToken, + props.dpopAccessToken, props.httpBaseUrl, + props.skills, ], ); + if (props.contentPresentation.kind === "loading") { + return ( + + ); + } + + if (props.contentPresentation.kind === "unavailable") { + return ( + + ); + } + if (props.feed.length === 0) { return ( - - - + ); } return ( <> - `${entry.type}:${entry.id}`} - getItemType={(entry) => - entry.type === "message" ? `message:${entry.message.role}` : entry.type - } - keyboardShouldPersistTaps="handled" - estimatedItemSize={180} - initialScrollAtEnd - maintainScrollAtEnd={{ - on: { layout: true, itemLayout: true, dataChange: true }, - }} - maintainScrollAtEndThreshold={0.1} - safeAreaInsetBottom={insets.bottom} - contentContainerStyle={{ - paddingTop: 12, - paddingHorizontal: horizontalPadding, - }} - /> + + `${entry.type}:${entry.id}`} + getItemType={(entry) => + entry.type === "message" ? `message:${entry.message.role}` : entry.type + } + keyboardShouldPersistTaps="always" + keyboardDismissMode="none" + estimatedItemSize={180} + initialScrollAtEnd + onContentSizeChange={onListContentSizeChange} + onLoad={onListLoad} + onScroll={onListScroll} + scrollEventThrottle={16} + ListHeaderComponent={} + contentContainerStyle={{ + paddingTop: 12, + paddingBottom: bottomContentInset, + paddingHorizontal: horizontalPadding, + }} + /> + ({ + (thread: EnvironmentThreadShell) => ({ activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(), title: thread.title, }), @@ -37,11 +42,9 @@ const threadActivityOrder = Order.mapInput( export function ThreadNavigationDrawer(props: { readonly visible: boolean; - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; readonly selectedThreadKey: string | null; readonly onClose: () => void; - readonly onSelectThread: (thread: EnvironmentScopedThreadShell) => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onStartNewTask: () => void; }) { const insets = useSafeAreaInsets(); @@ -57,26 +60,6 @@ export function ThreadNavigationDrawer(props: { const primaryForeground = useThemeColor("--color-primary-foreground"); const borderSubtleColor = useThemeColor("--color-border-subtle"); - const repositoryGroups = useMemo( - () => groupProjectsByRepository({ projects: props.projects, threads: props.threads }), - [props.projects, props.threads], - ); - const groupedThreads = useMemo( - () => - repositoryGroups.map((group) => { - const threads: EnvironmentScopedThreadShell[] = []; - for (const projectGroup of group.projects) { - threads.push(...projectGroup.threads); - } - return { - key: group.key, - title: group.projects[0]?.project.title ?? group.title, - threads: Arr.sort(threads, threadActivityOrder), - }; - }), - [repositoryGroups], - ); - useEffect(() => { if (props.visible) { setMounted(true); @@ -186,76 +169,116 @@ export function ThreadNavigationDrawer(props: { - - {groupedThreads.map((group) => ( - - - {group.title} - - - - {group.threads.length === 0 ? ( - - - No threads yet - - - ) : ( - group.threads.map((thread, index) => { - const threadKey = scopedThreadKey(thread.environmentId, thread.id); - const selected = props.selectedThreadKey === threadKey; - - return ( - { - props.onSelectThread(thread); - props.onClose(); - }} - style={{ - paddingHorizontal: 16, - paddingVertical: 15, - borderTopWidth: index === 0 ? 0 : 1, - borderTopColor: borderSubtleColor, - backgroundColor: selected ? undefined : "transparent", - }} - className={selected ? "bg-subtle" : undefined} - > - - - - {thread.title} - - - {relativeTime(thread.updatedAt ?? thread.createdAt)} - - - - - - ); - }) - )} - - - ))} - + ); } + +function ThreadNavigationDrawerContent(props: { + readonly bottomInset: number; + readonly borderSubtleColor: ColorValue; + readonly selectedThreadKey: string | null; + readonly onClose: () => void; + readonly onSelectThread: (thread: EnvironmentThreadShell) => void; +}) { + const projects = useProjects(); + const threads = useThreadShells(); + const repositoryGroups = useMemo( + () => groupProjectsByRepository({ projects, threads }), + [projects, threads], + ); + const groupedThreads = useMemo( + () => + repositoryGroups.map((group) => { + const threads: EnvironmentThreadShell[] = []; + for (const projectGroup of group.projects) { + threads.push(...projectGroup.threads); + } + return { + key: group.key, + title: group.projects[0]?.project.title ?? group.title, + threads: Arr.sort(threads, threadActivityOrder), + }; + }), + [repositoryGroups], + ); + + return ( + + {groupedThreads.map((group) => ( + + + {group.title} + + + + {group.threads.length === 0 ? ( + + + No threads yet + + + ) : ( + group.threads.map((thread, index) => { + const threadKey = scopedThreadKey(thread.environmentId, thread.id); + const selected = props.selectedThreadKey === threadKey; + + return ( + { + props.onSelectThread(thread); + props.onClose(); + }} + style={{ + paddingHorizontal: 16, + paddingVertical: 15, + borderTopWidth: index === 0 ? 0 : 1, + borderTopColor: props.borderSubtleColor, + backgroundColor: selected ? undefined : "transparent", + }} + className={selected ? "bg-subtle" : undefined} + > + + + + {thread.title} + + + {relativeTime(thread.updatedAt ?? thread.createdAt)} + + + + + + ); + }) + )} + + + ))} + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fd73a45b0c8..cedcb4fe999 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1,14 +1,14 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useCallback, useMemo, useState } from "react"; -import * as Arr from "effect/Array"; import * as Option from "effect/Option"; -import { pipe } from "effect/Function"; import { EnvironmentId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native"; +import { Pressable, ScrollView, Text as RNText, View } from "react-native"; +import { useWorkspaceState } from "../../state/workspace"; import { useThemeColor } from "../../lib/useThemeColor"; -import { useVcsStatus } from "../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../state/query"; import { dismissGitActionResult, useGitActionProgress } from "../../state/use-vcs-action-state"; +import { vcsEnvironment } from "../../state/vcs"; import { EmptyState } from "../../components/EmptyState"; import { LoadingScreen } from "../../components/LoadingScreen"; @@ -16,13 +16,13 @@ import { buildThreadRoutePath, buildThreadTerminalNavigation } from "../../lib/r import { scopedThreadKey } from "../../lib/scopedEntities"; import { connectionTone } from "../connection/connectionTone"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; import { + useRemoteConnections, useRemoteConnectionStatus, - useRemoteEnvironmentState, + useRemoteEnvironmentRuntime, } from "../../state/use-remote-environment-registry"; import { useKnownTerminalSessions } from "../../state/use-terminal-session"; -import { useSelectedThreadDetail } from "../../state/use-thread-detail"; +import { useSelectedThreadDetailState } from "../../state/use-thread-detail"; import { useThreadSelection } from "../../state/use-thread-selection"; import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; import { @@ -44,6 +44,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; +import { projectThreadContentPresentation } from "./threadContentPresentation"; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -58,14 +59,13 @@ function OpeningThreadLoadingScreen() { } export function ThreadRouteScreen() { - const { isLoadingSavedConnection, environmentStateById, pendingConnectionError } = - useRemoteEnvironmentState(); - const { connectionState, connectionError: aggregateConnectionError } = - useRemoteConnectionStatus(); - const { projects, threads } = useRemoteCatalog(); + const { state: workspaceState } = useWorkspaceState(); + const { connectionState } = useRemoteConnectionStatus(); + const { onReconnectEnvironment } = useRemoteConnections(); const { selectedThread, selectedThreadProject, selectedEnvironmentConnection } = useThreadSelection(); - const selectedThreadDetail = useSelectedThreadDetail(); + const selectedThreadDetailState = useSelectedThreadDetailState(); + const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); @@ -83,24 +83,25 @@ export function ThreadRouteScreen() { const environmentIdRaw = firstRouteParam(params.environmentId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const threadId = firstRouteParam(params.threadId); - const routeEnvironmentRuntime = environmentId - ? (environmentStateById[environmentId] ?? null) - : null; - const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? connectionState; - const routeConnectionError = - pendingConnectionError ?? routeEnvironmentRuntime?.connectionError ?? aggregateConnectionError; + const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); + const routeConnectionState = + routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); + const routeConnectionError = routeEnvironmentRuntime?.connectionError ?? null; /* ─── Native header theming ──────────────────────────────────────── */ - const isDark = useColorScheme() === "dark"; const iconColor = String(useThemeColor("--color-icon")); const foregroundColor = String(useThemeColor("--color-foreground")); - const secondaryFg = isDark ? "#a3a3a3" : "#525252"; + const secondaryFg = String(useThemeColor("--color-foreground-secondary")); /* ─── Git status for native header trigger ───────────────────────── */ - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const knownTerminalSessions = useKnownTerminalSessions({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, @@ -114,6 +115,12 @@ export function ThreadRouteScreen() { [knownTerminalSessions, selectedThreadProject?.workspaceRoot], ); const selectedThreadDetailWorktreePath = selectedThreadDetail?.worktreePath ?? null; + const handleReconnectEnvironment = useCallback(() => { + if (!environmentId) { + return; + } + onReconnectEnvironment(environmentId); + }, [environmentId, onReconnectEnvironment]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( @@ -239,7 +246,7 @@ export function ThreadRouteScreen() { if (!selectedThread) { const stillHydrating = - isLoadingSavedConnection || + workspaceState.isLoadingConnections || routeConnectionState === "connecting" || routeConnectionState === "reconnecting"; @@ -266,19 +273,14 @@ export function ThreadRouteScreen() { ); } - if (!selectedThreadDetail) { - return ; - } - const selectedThreadKey = scopedThreadKey(selectedThread.environmentId, selectedThread.id); - const serverConfig = - routeEnvironmentRuntime?.serverConfig ?? - pipe( - Object.values(environmentStateById), - Arr.map((runtime) => runtime.serverConfig), - Arr.findFirst((value) => value !== null), - Option.getOrNull, - ); + const contentPresentation = projectThreadContentPresentation({ + hasDetail: selectedThreadDetail !== null, + detailError: Option.getOrNull(selectedThreadDetailState.error), + detailDeleted: selectedThreadDetailState.status === "deleted", + connectionState: routeConnectionState, + }); + const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; const headerSubtitle = [ selectedThreadProject?.title ?? null, @@ -314,7 +316,7 @@ export function ThreadRouteScreen() { letterSpacing: -0.4, }} > - {selectedThreadDetail.title} + {selectedThread.title} setDrawerVisible(false)} onSelectThread={(thread) => { diff --git a/apps/mobile/src/features/threads/claudeEffortOptions.ts b/apps/mobile/src/features/threads/claudeEffortOptions.ts deleted file mode 100644 index 58a4032b0ba..00000000000 --- a/apps/mobile/src/features/threads/claudeEffortOptions.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const CLAUDE_AGENT_EFFORT_OPTIONS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultrathink", -] as const; - -export type ClaudeAgentEffort = (typeof CLAUDE_AGENT_EFFORT_OPTIONS)[number]; diff --git a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx index a6b29fbe431..3fbea89ba32 100644 --- a/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitBranchesSheet.tsx @@ -6,11 +6,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; export function GitBranchesSheet() { @@ -27,10 +28,14 @@ export function GitBranchesSheet() { const foregroundColor = useThemeColor("--color-foreground"); const subtleStrongColor = useThemeColor("--color-subtle-strong"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentWorktreePath = selectedThreadWorktreePath; diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index 478e2642035..9e20f5b1560 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -5,11 +5,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text, AppTextInput as TextInput } from "../../../components/AppText"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { SheetActionButton } from "./gitSheetComponents"; export function GitCommitSheet() { @@ -27,10 +28,14 @@ export function GitCommitSheet() { const inputBg = useThemeColor("--color-input"); const foregroundColor = useThemeColor("--color-foreground"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const busy = gitState.gitOperationLabel !== null; const isDefaultRef = gitStatus.data?.isDefaultRef ?? false; diff --git a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx index 65e0488622e..3d196715284 100644 --- a/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitConfirmSheet.tsx @@ -1,4 +1,4 @@ -import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime"; +import { resolveDefaultBranchActionDialogCopy } from "@t3tools/client-runtime/state/vcs"; import { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index a940fcdfcc3..314d0cfcd20 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -3,7 +3,7 @@ import { buildMenuItems, getGitActionDisabledReason, requiresDefaultBranchConfirmation, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { useLocalSearchParams, useRouter } from "expo-router"; import { SymbolView } from "expo-symbols"; @@ -14,11 +14,12 @@ import { useThemeColor } from "../../../lib/useThemeColor"; import { AppText as Text } from "../../../components/AppText"; import { buildThreadReviewRoutePath } from "../../../lib/routes"; -import { useVcsStatus } from "../../../state/use-vcs-status"; +import { useEnvironmentQuery } from "../../../state/query"; import { useThreadSelection } from "../../../state/use-thread-selection"; import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions"; import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state"; import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree"; +import { vcsEnvironment } from "../../../state/vcs"; import { MetaCard, SheetListRow, menuItemIconName, statusSummary } from "./gitSheetComponents"; export function GitOverviewSheet() { @@ -36,10 +37,14 @@ export function GitOverviewSheet() { const iconColor = useThemeColor("--color-icon"); const borderColor = useThemeColor("--color-border"); - const gitStatus = useVcsStatus({ - environmentId: selectedThread?.environmentId ?? null, - cwd: selectedThreadCwd, - }); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD"; const currentWorktreePath = selectedThreadWorktreePath; diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 1de8eaa688e..f26c8428fe1 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -4,12 +4,15 @@ import type { EnvironmentId, ModelSelection, ProviderInteractionMode, + ProviderOptionSelection, RuntimeMode, + ServerProviderSkill, } from "@t3tools/contracts"; import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; +import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../state/entities"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -22,21 +25,17 @@ import { setComposerDraftText, useComposerDraft, } from "../../state/use-composer-drafts"; -import { vcsRefManager, useVcsRefs } from "../../state/use-vcs-refs"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; +import { useBranches } from "../../state/queries"; import { setPendingConnectionError, - useRemoteEnvironmentState, + useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; -import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; -import type { ClaudeAgentEffort } from "./claudeEffortOptions"; +import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; -function normalizeSelectedWorktreePath( - project: EnvironmentScopedProjectShell, - branch: VcsRef, -): string | null { +function normalizeSelectedWorktreePath(project: EnvironmentProject, branch: VcsRef): string | null { if (!branch.worktreePath) { return null; } @@ -46,7 +45,7 @@ function normalizeSelectedWorktreePath( export function branchBadgeLabel(input: { readonly branch: VcsRef; - readonly project: EnvironmentScopedProjectShell | null; + readonly project: EnvironmentProject | null; }): string | null { if (input.branch.current) { return "current"; @@ -66,7 +65,7 @@ export function branchBadgeLabel(input: { type NewTaskFlowContextValue = { readonly logicalProjects: ReadonlyArray<{ readonly key: string; - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; }>; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; @@ -82,22 +81,20 @@ type NewTaskFlowContextValue = { readonly availableBranches: ReadonlyArray; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; - readonly effort: ClaudeAgentEffort; - readonly fastMode: boolean; - readonly contextWindow: string; readonly expandedProvider: string | null; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; }>; - readonly selectedProject: EnvironmentScopedProjectShell | null; + readonly selectedProject: EnvironmentProject | null; readonly modelOptions: ReadonlyArray; readonly selectedModel: ModelSelection | null; readonly selectedModelOption: ModelOption | null; + readonly selectedProviderSkills: ReadonlyArray; readonly providerGroups: ReadonlyArray; readonly filteredBranches: ReadonlyArray; readonly reset: () => void; - readonly setProject: (project: EnvironmentScopedProjectShell) => void; + readonly setProject: (project: EnvironmentProject) => void; readonly selectEnvironment: (environmentId: EnvironmentId) => void; readonly setSelectedModelKey: (key: string | null) => void; readonly setWorkspaceMode: (mode: WorkspaceMode) => void; @@ -112,17 +109,18 @@ type NewTaskFlowContextValue = { readonly loadBranches: () => Promise; readonly setRuntimeMode: (value: RuntimeMode) => void; readonly setInteractionMode: (value: ProviderInteractionMode) => void; - readonly setEffort: (value: ClaudeAgentEffort) => void; - readonly setFastMode: (value: boolean) => void; - readonly setContextWindow: (value: string) => void; + readonly setSelectedModelOptions: ( + value: ReadonlyArray | undefined, + ) => void; readonly setExpandedProvider: (value: string | null) => void; }; const NewTaskFlowContext = React.createContext(null); export function NewTaskFlowProvider(props: React.PropsWithChildren) { - const { projects, serverConfigByEnvironmentId, threads } = useRemoteCatalog(); - const { savedConnectionsById } = useRemoteEnvironmentState(); + const projects = useProjects(); + const threads = useThreadShells(); + const { savedConnectionsById } = useSavedRemoteConnections(); const repositoryGroups = useMemo( () => groupProjectsByRepository({ projects, threads }), @@ -144,7 +142,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { entry, ): entry is { readonly key: string; - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; } => entry !== null, ), ), @@ -166,9 +164,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const [interactionMode, setInteractionMode] = useState( DEFAULT_PROVIDER_INTERACTION_MODE, ); - const [effort, setEffort] = useState("high"); - const [fastMode, setFastMode] = useState(false); - const [contextWindow, setContextWindow] = useState("1M"); + const [modelSelectionOverrides, setModelSelectionOverrides] = useState< + Record + >({}); const [expandedProvider, setExpandedProvider] = useState(null); const reset = useCallback(() => { @@ -186,9 +184,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setBranchQuery(""); setRuntimeMode(DEFAULT_RUNTIME_MODE); setInteractionMode(DEFAULT_PROVIDER_INTERACTION_MODE); - setEffort("high"); - setFastMode(false); - setContextWindow("1M"); + setModelSelectionOverrides({}); setExpandedProvider(null); }, [projects]); @@ -252,6 +248,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ) ?? projectsForEnvironment[0] ?? null; + const selectedEnvironmentServerConfig = useEnvironmentServerConfig( + selectedProject?.environmentId ?? null, + ); const selectedProjectDraftKey = selectedProject ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` : null; @@ -262,19 +261,29 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const modelOptions = useMemo( () => buildModelOptions( - selectedProject - ? (serverConfigByEnvironmentId[selectedProject.environmentId] ?? null) - : null, + selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection ?? null, ), - [selectedProject, serverConfigByEnvironmentId], + [selectedEnvironmentServerConfig, selectedProject?.defaultModelSelection], ); - const selectedModel = + const defaultModelKey = selectedProject?.defaultModelSelection + ? `${selectedProject.defaultModelSelection.instanceId}:${selectedProject.defaultModelSelection.model}` + : null; + const baseSelectedModel = modelOptions.find((option) => option.key === selectedModelKey)?.selection ?? + (defaultModelKey + ? modelOptions.find((option) => option.key === defaultModelKey)?.selection + : null) ?? selectedProject?.defaultModelSelection ?? modelOptions[0]?.selection ?? null; + const selectedModelIdentity = baseSelectedModel + ? `${baseSelectedModel.instanceId}:${baseSelectedModel.model}` + : null; + const selectedModel = + (selectedModelIdentity ? modelSelectionOverrides[selectedModelIdentity] : null) ?? + baseSelectedModel; const selectedModelOption = modelOptions.find( @@ -283,6 +292,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; + const selectedProviderSkills = + selectedEnvironmentServerConfig?.providers.find( + (provider) => provider.instanceId === selectedModel?.instanceId, + )?.skills ?? []; + const setSelectedModelOptions = useCallback( + (options: ReadonlyArray | undefined) => { + if (!selectedModel || !selectedModelIdentity) { + return; + } + const nextSelection: ModelSelection = options + ? { ...selectedModel, options } + : { + instanceId: selectedModel.instanceId, + model: selectedModel.model, + }; + setModelSelectionOverrides((current) => ({ + ...current, + [selectedModelIdentity]: nextSelection, + })); + }, + [selectedModel, selectedModelIdentity], + ); const providerGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]); const setPrompt = useCallback( @@ -335,7 +366,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { }), [selectedProject?.environmentId, selectedProject?.workspaceRoot], ); - const branchState = useVcsRefs(branchTarget); + const branchState = useBranches(branchTarget); const branchesLoading = branchState.isPending; const availableBranches = useMemo( () => @@ -358,13 +389,14 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback((project: EnvironmentScopedProjectShell) => { + const setProject = useCallback((project: EnvironmentProject) => { const nextProjectKey = scopedProjectKey(project.environmentId, project.id); branchLoadVersionRef.current += 1; setSelectedEnvironmentId(project.environmentId); setSelectedProjectKey(nextProjectKey); setSelectedBranchName(null); setSelectedWorktreePath(null); + setModelSelectionOverrides({}); }, []); const selectEnvironment = useCallback((environmentId: EnvironmentId) => { @@ -373,6 +405,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setSelectedProjectKey(null); setSelectedBranchName(null); setSelectedWorktreePath(null); + setModelSelectionOverrides({}); }, []); const selectBranch = useCallback( @@ -392,37 +425,28 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const loadVersion = ++branchLoadVersionRef.current; const projectKey = scopedProjectKey(selectedProject.environmentId, selectedProject.id); - try { - const result = await vcsRefManager.load({ - environmentId: selectedProject.environmentId, - cwd: selectedProject.workspaceRoot, - query: null, - }); - if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { - return; - } - setPendingConnectionError(null); - const branches = pipe( - result?.refs ?? [], - Arr.filter((branch) => !branch.isRemote), - ); - - if (workspaceMode === "worktree" && !selectedBranchName) { - const preferredBranch = - branches.find((branch) => branch.current)?.name ?? - branches.find((branch) => branch.isDefault)?.name ?? - null; - if (preferredBranch) { - setSelectedBranchName(preferredBranch); - } - } - } catch { - if (loadVersion !== branchLoadVersionRef.current) { - return; + branchState.refresh(); + if (loadVersion !== branchLoadVersionRef.current || selectedProjectKey !== projectKey) { + return; + } + setPendingConnectionError(null); + if (workspaceMode === "worktree" && !selectedBranchName) { + const preferredBranch = + availableBranches.find((branch) => branch.current)?.name ?? + availableBranches.find((branch) => branch.isDefault)?.name ?? + null; + if (preferredBranch) { + setSelectedBranchName(preferredBranch); } - setPendingConnectionError("Failed to load branches."); } - }, [selectedBranchName, selectedProject, selectedProjectKey, workspaceMode]); + }, [ + availableBranches, + branchState, + selectedBranchName, + selectedProject, + selectedProjectKey, + workspaceMode, + ]); const value = useMemo( () => ({ @@ -441,15 +465,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, runtimeMode, interactionMode, - effort, - fastMode, - contextWindow, expandedProvider, environments, selectedProject, modelOptions, selectedModel, selectedModelOption, + selectedProviderSkills, providerGroups, filteredBranches, reset, @@ -468,9 +490,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { loadBranches, setRuntimeMode, setInteractionMode, - setEffort, - setFastMode, - setContextWindow, + setSelectedModelOptions, setExpandedProvider, }), [ @@ -478,11 +498,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { availableBranches, branchQuery, branchesLoading, - contextWindow, - effort, environments, expandedProvider, - fastMode, filteredBranches, interactionMode, loadBranches, @@ -498,6 +515,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedModel, selectedModelKey, selectedModelOption, + selectedProviderSkills, + setSelectedModelOptions, selectedProject, selectedProjectKey, selectedWorktreePath, diff --git a/apps/mobile/src/features/threads/threadContentPresentation.test.ts b/apps/mobile/src/features/threads/threadContentPresentation.test.ts new file mode 100644 index 00000000000..f179e756fbf --- /dev/null +++ b/apps/mobile/src/features/threads/threadContentPresentation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { projectThreadContentPresentation } from "./threadContentPresentation"; + +describe("thread content presentation", () => { + it("renders cached detail while its environment reconnects", () => { + expect( + projectThreadContentPresentation({ + hasDetail: true, + detailError: null, + detailDeleted: false, + connectionState: "reconnecting", + }), + ).toEqual({ kind: "ready" }); + }); + + it("loads missing detail inside the thread screen when connected", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: null, + detailDeleted: false, + connectionState: "connected", + }), + ).toEqual({ kind: "loading" }); + }); + + it("explains uncached detail while disconnected instead of loading forever", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: null, + detailDeleted: false, + connectionState: "error", + }), + ).toEqual({ + kind: "unavailable", + title: "Messages not cached", + detail: "Reconnect this environment to load the conversation.", + }); + }); + + it("surfaces detail errors before presenting a loading state", () => { + expect( + projectThreadContentPresentation({ + hasDetail: false, + detailError: "The thread stream failed.", + detailDeleted: false, + connectionState: "connected", + }), + ).toEqual({ + kind: "unavailable", + title: "Could not load conversation", + detail: "The thread stream failed.", + }); + }); +}); diff --git a/apps/mobile/src/features/threads/threadContentPresentation.ts b/apps/mobile/src/features/threads/threadContentPresentation.ts new file mode 100644 index 00000000000..c806e6dfc46 --- /dev/null +++ b/apps/mobile/src/features/threads/threadContentPresentation.ts @@ -0,0 +1,43 @@ +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; + +export type ThreadContentPresentation = + | { readonly kind: "ready" } + | { readonly kind: "loading" } + | { + readonly kind: "unavailable"; + readonly title: string; + readonly detail: string; + }; + +export function projectThreadContentPresentation(input: { + readonly hasDetail: boolean; + readonly detailError: string | null; + readonly detailDeleted: boolean; + readonly connectionState: EnvironmentConnectionPhase; +}): ThreadContentPresentation { + if (input.hasDetail) { + return { kind: "ready" }; + } + if (input.detailDeleted) { + return { + kind: "unavailable", + title: "Thread unavailable", + detail: "This thread was deleted or is no longer available.", + }; + } + if (input.detailError !== null) { + return { + kind: "unavailable", + title: "Could not load conversation", + detail: input.detailError, + }; + } + if (input.connectionState === "connected") { + return { kind: "loading" }; + } + return { + kind: "unavailable", + title: "Messages not cached", + detail: "Reconnect this environment to load the conversation.", + }; +} diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 4253cedbc7e..9a1bc67c27c 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -1,12 +1,12 @@ import type { StatusTone } from "../../components/StatusPill"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -export function threadSortValue(thread: EnvironmentScopedThreadShell): number { +export function threadSortValue(thread: EnvironmentThreadShell): number { const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); return Number.isNaN(candidate) ? 0 : candidate; } -export function threadStatusTone(thread: EnvironmentScopedThreadShell): StatusTone { +export function threadStatusTone(thread: EnvironmentThreadShell): StatusTone { const status = thread.session?.status; if (status === "running") { return { diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 029e1bbdcf6..1b66ac2e250 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -1,67 +1,25 @@ +import { useAtomSet } from "@effect/atom-react"; import { useCallback } from "react"; -import { EnvironmentScopedProjectShell, type VcsRef } from "@t3tools/client-runtime"; +import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { - CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, - type EnvironmentId, + CommandId, MessageId, ThreadId, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { buildTemporaryWorktreeBranchName, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { uuidv4 } from "../../lib/uuid"; +import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { threadEnvironment } from "../../state/threads"; +import { useThreadShells } from "../../state/entities"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { makeTurnCommandMetadata } from "../../lib/commandMetadata"; -import { getEnvironmentClient } from "../../state/environment-session-registry"; -import { environmentRuntimeManager } from "../../state/use-environment-runtime"; -import { vcsRefManager } from "../../state/use-vcs-refs"; -import { useRemoteCatalog } from "../../state/use-remote-catalog"; -import { - setPendingConnectionError, - useRemoteEnvironmentState, -} from "../../state/use-remote-environment-registry"; - -function useRefreshRemoteData() { - const { savedConnectionsById } = useRemoteEnvironmentState(); - - return useCallback( - async (environmentIds?: ReadonlyArray) => { - const targets = - environmentIds ?? - Object.values(savedConnectionsById).map((connection) => connection.environmentId); - - await Promise.all( - targets.map(async (environmentId) => { - const client = getEnvironmentClient(environmentId); - if (!client) { - return; - } - - try { - const serverConfig = await client.server.getConfig(); - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - serverConfig, - connectionError: null, - })); - } catch (error) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionError: - error instanceof Error ? error.message : "Failed to refresh remote data.", - })); - } - }), - ); - }, - [savedConnectionsById], - ); -} +import { uuidv4 } from "../../lib/uuid"; +import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; function deriveThreadTitleFromPrompt(value: string): string { const trimmed = value.trim(); @@ -74,12 +32,12 @@ function deriveThreadTitleFromPrompt(value: string): string { } export function useProjectActions() { - const { threads } = useRemoteCatalog(); - const refreshRemoteData = useRefreshRemoteData(); + const startTurn = useAtomSet(threadEnvironment.startTurn, { mode: "promise" }); + const threads = useThreadShells(); const onCreateThreadWithOptions = useCallback( async (input: { - readonly project: EnvironmentScopedProjectShell; + readonly project: EnvironmentProject; readonly modelSelection: ModelSelection; readonly envMode: "local" | "worktree"; readonly branch: string | null; @@ -89,14 +47,8 @@ export function useProjectActions() { readonly initialMessageText: string; readonly initialAttachments: ReadonlyArray; }) => { - const client = getEnvironmentClient(input.project.environmentId); - if (!client) { - return null; - } - const metadata = makeTurnCommandMetadata(); const threadId = ThreadId.make(metadata.threadId); - const createdAt = metadata.createdAt; const initialMessageText = input.initialMessageText.trim(); const nextTitle = deriveThreadTitleFromPrompt(input.initialMessageText); @@ -108,57 +60,57 @@ export function useProjectActions() { } const isWorktree = input.envMode === "worktree"; - - await client.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: CommandId.make(metadata.commandId), - threadId, - message: { - messageId: MessageId.make(metadata.messageId), - role: "user", - text: initialMessageText, - attachments: input.initialAttachments, - }, - modelSelection: input.modelSelection, - titleSeed: nextTitle, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - bootstrap: { - createThread: { - projectId: input.project.id, - title: nextTitle, - modelSelection: input.modelSelection, - runtimeMode: input.runtimeMode, - interactionMode: input.interactionMode, - branch: input.branch, - worktreePath: isWorktree ? null : input.worktreePath, - createdAt, + await startTurn({ + environmentId: input.project.environmentId, + input: { + commandId: CommandId.make(metadata.commandId), + threadId, + message: { + messageId: MessageId.make(metadata.messageId), + role: "user", + text: initialMessageText, + attachments: input.initialAttachments, + }, + modelSelection: input.modelSelection, + titleSeed: nextTitle, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + bootstrap: { + createThread: { + projectId: input.project.id, + title: nextTitle, + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + branch: input.branch, + worktreePath: isWorktree ? null : input.worktreePath, + createdAt: metadata.createdAt, + }, + ...(isWorktree + ? { + prepareWorktree: { + projectCwd: input.project.workspaceRoot, + baseBranch: input.branch!, + branch: buildTemporaryWorktreeBranchName(uuidv4), + }, + runSetupScript: true, + } + : {}), }, - ...(isWorktree - ? { - prepareWorktree: { - projectCwd: input.project.workspaceRoot, - baseBranch: input.branch!, - branch: buildTemporaryWorktreeBranchName(uuidv4), - }, - runSetupScript: true, - } - : {}), + createdAt: metadata.createdAt, }, - createdAt, }); - await refreshRemoteData([input.project.environmentId]); return { environmentId: input.project.environmentId, threadId, }; }, - [refreshRemoteData], + [startTurn], ); const onCreateThread = useCallback( - async (project: EnvironmentScopedProjectShell) => { + async (project: EnvironmentProject) => { const latestProjectThread = threads.find( (thread) => @@ -186,77 +138,8 @@ export function useProjectActions() { [onCreateThreadWithOptions, threads], ); - const onListProjectBranches = useCallback( - async (project: EnvironmentScopedProjectShell): Promise> => { - const client = getEnvironmentClient(project.environmentId); - if (!client) { - return []; - } - - try { - const result = await vcsRefManager.load( - { environmentId: project.environmentId, cwd: project.workspaceRoot, query: null }, - client.vcs, - { limit: 100 }, - ); - return (result?.refs ?? []).filter((branch) => !branch.isRemote); - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to load branches.", - ); - return []; - } - }, - [], - ); - - const onCreateProjectWorktree = useCallback( - async ( - project: EnvironmentScopedProjectShell, - nextWorktree: { - readonly baseBranch: string; - readonly newBranch: string; - }, - ): Promise<{ - readonly branch: string; - readonly worktreePath: string; - } | null> => { - const client = getEnvironmentClient(project.environmentId); - if (!client) { - return null; - } - - try { - const result = await client.vcs.createWorktree({ - cwd: project.workspaceRoot, - refName: nextWorktree.baseBranch, - newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), - path: null, - }); - vcsRefManager.invalidate({ - environmentId: project.environmentId, - cwd: project.workspaceRoot, - query: null, - }); - return { - branch: result.worktree.refName, - worktreePath: result.worktree.path, - }; - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to create worktree.", - ); - return null; - } - }, - [], - ); - return { onCreateThread, onCreateThreadWithOptions, - onListProjectBranches, - onCreateProjectWorktree, - onRefreshProjects: refreshRemoteData, }; } diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index b341c7b6bd4..09897b6186e 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,7 +1,7 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { Platform } from "react-native"; -export function mobileAuthClientMetadata(): AuthClientPresentationMetadata { +export function authClientMetadata(): AuthClientPresentationMetadata { return { label: "T3 Code Mobile", deviceType: "mobile", diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 68813b0b3b1..f1f30b298b6 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -3,13 +3,13 @@ import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, - mobileAuthClientMetadata, + authClientMetadata, redactPairingCredential, toStableSavedRemoteConnection, } from "./connection"; vi.mock("./runtime", () => ({ - mobileRuntime: { + runtime: { runPromise: vi.fn(), }, })); @@ -22,7 +22,7 @@ vi.mock("react-native", () => ({ describe("mobile remote connection records", () => { it("identifies mobile token exchanges for authorized-client presentation", () => { - expect(mobileAuthClientMetadata()).toEqual({ + expect(authClientMetadata()).toEqual({ label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index aa92c6f5d58..839bc70e6d9 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,18 +1,8 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { - bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, -} from "@t3tools/client-runtime"; -import { resolveRemotePairingTarget, stripPairingTokenFromUrl } from "@t3tools/shared/remote"; -import * as Effect from "effect/Effect"; -import { mobileAuthClientMetadata } from "./authClientMetadata"; -import { mobileRuntime } from "./runtime"; +import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; -export { mobileAuthClientMetadata } from "./authClientMetadata"; - -export interface RemoteConnectionInput { - readonly pairingUrl: string; -} +export { authClientMetadata } from "./authClientMetadata"; export interface SavedRemoteConnection { readonly environmentId: EnvironmentId; @@ -27,12 +17,7 @@ export interface SavedRemoteConnection { readonly relayManaged?: true; } -export type RemoteClientConnectionState = - | "idle" - | "connecting" - | "ready" - | "reconnecting" - | "disconnected"; +export type RemoteClientConnectionState = EnvironmentConnectionPhase; export function redactPairingCredential(pairingUrl: string): string { const trimmed = pairingUrl.trim(); @@ -59,38 +44,3 @@ export function toStableSavedRemoteConnection( const { dpopAccessToken: _, ...stableConnection } = connection; return stableConnection; } - -export async function bootstrapRemoteConnection( - input: RemoteConnectionInput, -): Promise { - const target = resolveRemotePairingTarget({ - pairingUrl: input.pairingUrl, - }); - - const { descriptor, bootstrap } = await mobileRuntime.runPromise( - Effect.all( - { - descriptor: fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: target.httpBaseUrl, - }), - bootstrap: bootstrapRemoteBearerSession({ - httpBaseUrl: target.httpBaseUrl, - credential: target.credential, - clientMetadata: mobileAuthClientMetadata(), - }), - }, - { concurrency: "unbounded" }, - ), - ); - - return { - environmentId: descriptor.environmentId, - environmentLabel: descriptor.label, - pairingUrl: redactPairingCredential(input.pairingUrl), - displayUrl: target.httpBaseUrl, - httpBaseUrl: target.httpBaseUrl, - wsBaseUrl: target.wsBaseUrl, - bearerToken: bootstrap.access_token, - authenticationMethod: "bearer", - }; -} diff --git a/apps/mobile/src/lib/copyTextWithHaptic.test.ts b/apps/mobile/src/lib/copyTextWithHaptic.test.ts new file mode 100644 index 00000000000..d15a3a1a59b --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + impactAsync: vi.fn(), + setStringAsync: vi.fn(), +})); + +vi.mock("expo-clipboard", () => ({ + setStringAsync: mocks.setStringAsync, +})); + +vi.mock("expo-haptics", () => ({ + ImpactFeedbackStyle: { + Light: "light", + }, + impactAsync: mocks.impactAsync, +})); + +import { copyTextWithHaptic } from "./copyTextWithHaptic"; + +describe("copyTextWithHaptic", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.setStringAsync.mockReturnValue(new Promise(() => undefined)); + mocks.impactAsync.mockResolvedValue(undefined); + }); + + it("triggers haptic feedback without waiting for the clipboard promise", () => { + copyTextWithHaptic("trace-123"); + + expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); + expect(mocks.impactAsync).toHaveBeenCalledWith("light"); + }); +}); diff --git a/apps/mobile/src/lib/copyTextWithHaptic.ts b/apps/mobile/src/lib/copyTextWithHaptic.ts new file mode 100644 index 00000000000..80f725f5b00 --- /dev/null +++ b/apps/mobile/src/lib/copyTextWithHaptic.ts @@ -0,0 +1,7 @@ +import * as Clipboard from "expo-clipboard"; +import * as Haptics from "expo-haptics"; + +export function copyTextWithHaptic(value: string): void { + void Clipboard.setStringAsync(value); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); +} diff --git a/apps/mobile/src/lib/mobileLayout.ts b/apps/mobile/src/lib/layout.ts similarity index 74% rename from apps/mobile/src/lib/mobileLayout.ts rename to apps/mobile/src/lib/layout.ts index 0ae284e463f..2ae4314fdba 100644 --- a/apps/mobile/src/lib/mobileLayout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -2,19 +2,16 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export type MobileLayoutVariant = "compact" | "split"; +export type LayoutVariant = "compact" | "split"; -export interface MobileLayout { - readonly variant: MobileLayoutVariant; +export interface Layout { + readonly variant: LayoutVariant; readonly usesSplitView: boolean; readonly listPaneWidth: number | null; readonly shellPadding: number; } -export function deriveMobileLayout(input: { - readonly width: number; - readonly height: number; -}): MobileLayout { +export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout { const { width, height } = input; const shortestEdge = Math.min(width, height); const wideEnoughForSplit = width >= 900 || (width >= 700 && shortestEdge >= 700); diff --git a/apps/mobile/src/lib/markdownLinks.test.ts b/apps/mobile/src/lib/markdownLinks.test.ts new file mode 100644 index 00000000000..8a5c9d56b1e --- /dev/null +++ b/apps/mobile/src/lib/markdownLinks.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; + +describe("resolveMarkdownLinkPresentation", () => { + it("extracts external link hosts", () => { + expect(resolveMarkdownLinkPresentation("https://example.com/docs?q=1")).toEqual({ + kind: "external", + href: "https://example.com/docs?q=1", + host: "example.com", + }); + }); + + it("renders file URLs as basename pills with positions", () => { + expect( + resolveMarkdownLinkPresentation("file:///Users/julius/project/src/main.ts#L42C7"), + ).toEqual({ + kind: "file", + icon: "typescript", + label: "main.ts:42:7", + }); + }); + + it("recognizes relative source paths and bare filenames", () => { + expect(resolveMarkdownLinkPresentation("apps/mobile/src/index.ts:10")).toEqual({ + kind: "file", + icon: "typescript", + label: "index.ts:10", + }); + expect(resolveMarkdownLinkPresentation("AGENTS.md")).toEqual({ + kind: "file", + icon: "agents", + label: "AGENTS.md", + }); + expect(resolveMarkdownLinkPresentation("package.json")).toEqual({ + kind: "file", + icon: "npm", + label: "package.json", + }); + }); + + it("does not style app routes as file links", () => { + expect(resolveMarkdownLinkPresentation("/chat/settings")).toEqual({ + kind: "link", + href: null, + }); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts new file mode 100644 index 00000000000..9a71640b45a --- /dev/null +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; + +import { buildModelOptions } from "./modelOptions"; + +describe("mobile model options", () => { + it("normalizes a legacy fallback selection against current capabilities", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-test", + name: "GPT Test", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + currentValue: "default", + }, + ], + }, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const [option] = buildModelOptions(config, { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-test", + options: [{ id: "fastMode", value: true }], + }); + + expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); + expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); + }); +}); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 778e5bfb5b5..e21682414d7 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -1,4 +1,12 @@ -import type { ModelSelection, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; +import type { + ModelCapabilities, + ModelSelection, + ServerConfig as T3ServerConfig, +} from "@t3tools/contracts"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; export type ModelOption = { readonly key: string; @@ -7,6 +15,7 @@ export type ModelOption = { readonly providerKey: string; readonly providerLabel: string; readonly providerDriver: string; + readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -27,6 +36,27 @@ function providerDisplayLabel(provider: { return provider.instanceId; } +function normalizeSelectionOptions( + selection: ModelSelection, + capabilities: ModelCapabilities | null, +): ModelSelection { + if (!capabilities) { + return selection; + } + const options = buildProviderOptionSelectionsFromDescriptors( + getProviderOptionDescriptors({ + caps: capabilities, + selections: selection.options, + }), + ); + return options + ? { ...selection, options } + : { + instanceId: selection.instanceId, + model: selection.model, + }; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, @@ -48,17 +78,27 @@ export function buildModelOptions( providerKey: provider.instanceId, providerLabel, providerDriver: provider.driver, - selection: { - instanceId: provider.instanceId, - model: model.slug, - }, + capabilities: model.capabilities, + selection: normalizeSelectionOptions( + { + instanceId: provider.instanceId, + model: model.slug, + }, + model.capabilities, + ), }); } } if (fallbackModelSelection) { const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; - if (!options.has(key)) { + const existing = options.get(key); + if (existing) { + options.set(key, { + ...existing, + selection: normalizeSelectionOptions(fallbackModelSelection, existing.capabilities), + }); + } else { const providerLabel = fallbackModelSelection.instanceId; options.set(key, { key, @@ -67,6 +107,7 @@ export function buildModelOptions( providerKey: fallbackModelSelection.instanceId, providerLabel, providerDriver: fallbackModelSelection.instanceId, + capabilities: null, selection: fallbackModelSelection, }); } diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts new file mode 100644 index 00000000000..587bcf08b51 --- /dev/null +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -0,0 +1,734 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { + nativeMarkdownChunkSpacing, + nativeMarkdownDocumentChunks, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + nativeMarkdownTextRuns, +} from "@t3tools/mobile-markdown-text/markdown"; + +describe("nativeMarkdownTextRuns", () => { + it("preserves inline emphasis and code styles", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "plain " }, + { type: "bold", children: [{ type: "text", content: "bold" }] }, + { type: "text", content: " " }, + { type: "code_inline", content: "const value = 1" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "plain " }, + { text: "bold", bold: true }, + { text: " " }, + { text: "const value = 1", code: true }, + ]); + }); + + it("normalizes external and file links for native presentation", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "link", + href: "https://example.com/docs", + children: [{ type: "text", content: "Docs" }], + }, + { type: "text", content: " " }, + { + type: "link", + href: "file:///repo/README.md#L12", + children: [{ type: "text", content: "ignored label" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { + text: "Docs", + href: "https://example.com/docs", + externalHost: "example.com", + }, + { text: " " }, + { text: "README.md:12", fileIcon: "markdown" }, + ]); + }); + + it("keeps hard breaks and collapses soft breaks", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "first" }, + { type: "soft_break" }, + { type: "text", content: "second" }, + { type: "line_break" }, + { type: "text", content: "third" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "first second\nthird" }]); + }); + + it("normalizes common inline HTML and entities", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { type: "text", content: "Less than: < " }, + { type: "html_inline", content: "" }, + { type: "text", content: "⌘" }, + { type: "html_inline", content: "" }, + { type: "html_inline", content: "
" }, + { type: "html_inline", content: "highlighted" }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: "Less than: < ⌘\nhighlighted" }]); + }); + + it("normalizes double-encoded entities and inline tags emitted as text", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + content: + "Keyboard: + K; Less than: &lt;; Greater than: &gt;", + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Keyboard: ⌘ + K; Less than: <; Greater than: >" }, + ]); + }); + + it("reads inline content from nested text nodes", () => { + const node: MarkdownNode = { + type: "paragraph", + children: [ + { + type: "text", + children: [{ type: "text", content: "Plain text" }], + }, + { type: "text", content: " and " }, + { + type: "code_inline", + children: [{ type: "text", content: "inline code" }], + }, + ], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([ + { text: "Plain text and " }, + { text: "inline code", code: true }, + ]); + }); +}); + +describe("nativeMarkdownDocumentRuns", () => { + it("decorates known skill references as selectable skill chips", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ + { text: "Use ", role: "body" }, + { + text: "$ui", + role: "body", + skillName: "ui", + skillLabel: "UI", + }, + { text: " for this.", role: "body" }, + ]); + }); + + it("leaves unknown skill-like text unchanged", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $unknown for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ + { text: "Use $unknown for this.", role: "body" }, + ]); + }); + + it("keeps headings, paragraphs, and lists in one continuous document", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Header One" }], + }, + { + type: "paragraph", + children: [ + { type: "text", content: "A paragraph with " }, + { type: "bold", children: [{ type: "text", content: "bold text" }] }, + { type: "text", content: "." }, + ], + }, + { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "First item" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Second item" }], + }, + ], + }, + ], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe( + "Header One\n\nA paragraph with bold text.\n\n•\tFirst item\n•\tSecond item", + ); + expect(runs).toContainEqual({ + text: "Header One\n", + role: "heading", + headingLevel: 1, + }); + expect(runs).toContainEqual({ + text: "bold text", + bold: true, + role: "body", + }); + expect(runs).toContainEqual({ + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }); + }); + + it("uses distinct section, heading-content, and body spacing", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Intro" }], + }, + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "First paragraph" }], + }, + { + type: "paragraph", + children: [{ type: "text", content: "Second paragraph" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .filter((run) => run.role === "spacer") + .map((run) => run.spacing), + ).toEqual([20, 10, 12]); + }); + + it("renders tight list items whose inline nodes are direct children", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "bold", + children: [{ type: "text", content: "Finding:" }], + }, + { type: "text", content: " details with " }, + { type: "code_inline", content: "inline code" }, + { type: "text", content: "." }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node)).toEqual([ + { + text: "•\t", + role: "list-marker", + depth: 1, + firstLineHeadIndent: 0, + headIndent: 24, + paragraphSpacing: 2, + }, + { text: "Finding:", bold: true, role: "body", depth: 1 }, + { text: " details with ", role: "body", depth: 1 }, + { text: "inline code", code: true, role: "body", depth: 1 }, + { text: ".", role: "body", depth: 1 }, + ]); + }); + + it("includes quotes and fenced code in the same selectable string", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Read this" }], + }, + ], + }, + { + type: "code_block", + language: "ts", + content: "const answer = 42;", + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node); + expect(runs.map((run) => run.text).join("")).toBe("│\u00a0Read this\n\nTS\nconst answer = 42;"); + expect(runs).toContainEqual({ + text: "const answer = 42;", + code: true, + role: "code-block", + }); + }); + + it("reads fenced code content from child text nodes", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node) + .map((run) => run.text) + .join(""), + ).toBe("BASH\npnpm install"); + }); +}); + +describe("nativeMarkdownListItemBlocks", () => { + it("groups consecutive inline nodes into one paragraph block", () => { + const item: MarkdownNode = { + type: "list_item", + children: [ + { type: "text", content: "Finding: " }, + { type: "bold", children: [{ type: "text", content: "important" }] }, + { type: "text", content: " details." }, + { + type: "list", + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + { type: "text", content: "Trailing prose." }, + ], + }; + + expect(nativeMarkdownListItemBlocks(item)).toEqual([ + { + type: "paragraph", + children: item.children?.slice(0, 3), + }, + item.children?.[3], + { + type: "paragraph", + children: [item.children?.[4]], + }, + ]); + }); +}); + +describe("nativeMarkdownDocumentChunks", () => { + it("keeps headings and plain lists in one selectable document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Tasks" }], + }, + { + type: "list", + children: [ + { + type: "task_list_item", + checked: true, + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Completed" }], + }, + ], + }, + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Parent" }], + }, + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Nested" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect( + nativeMarkdownDocumentRuns(chunks[0]?.node ?? document) + .map((run) => run.text) + .join(""), + ).toBe("Tasks\n\n☑︎\tCompleted\n•\tParent\n◦\tNested"); + }); + + it("aligns ordered markers while keeping the list in one selectable string", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + ordered: true, + start: 9, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "Ninth" }], + }, + { + type: "list_item", + children: [{ type: "text", content: "Tenth" }], + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(document) + .map((run) => run.text) + .join(""), + ).toBe("\u20079.\tNinth\n10.\tTenth"); + }); + + it("keeps prose selectable while exposing rich AST blocks", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + beg: 0, + end: 9, + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + beg: 11, + end: 35, + children: [{ type: "text", content: "pnpm install\n" }], + }, + { + type: "paragraph", + beg: 37, + end: 42, + children: [{ type: "text", content: "Done." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:code_block:11:35", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps a list containing fenced code as one rich AST container", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "list", + beg: 0, + end: 45, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + ], + }; + + expect(nativeMarkdownDocumentChunks(document)).toEqual([ + { + kind: "rich", + key: "rich:list:0:45", + node: document.children?.[0], + }, + ]); + }); + + it("keeps surrounding prose selectable when rich nodes have no source offsets", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "heading", + level: 1, + children: [{ type: "text", content: "Before" }], + }, + { type: "horizontal_rule" }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:horizontal_rule:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("keeps offset-free structural lists isolated without promoting the whole document", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "list", + ordered: true, + children: [ + { + type: "list_item", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Install" }], + }, + { + type: "code_block", + language: "bash", + children: [{ type: "text", content: "pnpm install\n" }], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks).toHaveLength(3); + expect(chunks[0]).toMatchObject({ kind: "selectable" }); + expect(chunks[1]).toEqual({ + kind: "rich", + key: "rich:list:1:1", + node: document.children?.[1], + }); + expect(chunks[2]).toMatchObject({ kind: "selectable" }); + }); + + it("never collapses a rich subtree into a second markdown parsing pass", () => { + const document: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Before." }], + }, + { + type: "blockquote", + children: [ + { + type: "list", + children: [ + { + type: "list_item", + children: [ + { type: "text", content: "Run this" }, + { + type: "code_block", + language: "sh", + children: [{ type: "text", content: "vp check\n" }], + }, + ], + }, + ], + }, + ], + }, + { + type: "paragraph", + children: [{ type: "text", content: "After." }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks(document); + expect(chunks.map((chunk) => chunk.kind)).toEqual(["selectable", "rich", "selectable"]); + expect(chunks[1]).toMatchObject({ + kind: "rich", + node: { type: "blockquote" }, + }); + }); + + it("keeps a plain list in one selectable native text container", () => { + const list: MarkdownNode = { + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [{ type: "text", content: "First" }], + }, + ], + }; + + const chunks = nativeMarkdownDocumentChunks({ + type: "document", + children: [list], + }); + + expect(chunks).toHaveLength(1); + expect(chunks[0]).toMatchObject({ + kind: "selectable", + node: { type: "document", children: [list] }, + }); + }); + + it("separates sections more than related rich blocks", () => { + const headingChunk = { + kind: "selectable" as const, + key: "heading", + node: { + type: "document", + children: [ + { + type: "heading", + level: 2, + children: [{ type: "text", content: "Section" }], + }, + ], + } satisfies MarkdownNode, + }; + const firstList = { + kind: "rich" as const, + key: "list-1", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + const secondList = { + kind: "rich" as const, + key: "list-2", + node: { type: "list", children: [] } satisfies MarkdownNode, + }; + + expect(nativeMarkdownChunkSpacing(undefined, headingChunk)).toBe(0); + expect(nativeMarkdownChunkSpacing(headingChunk, firstList)).toBe(10); + expect(nativeMarkdownChunkSpacing(firstList, secondList)).toBe(12); + expect(nativeMarkdownChunkSpacing(firstList, headingChunk)).toBe(20); + }); +}); diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts new file mode 100644 index 00000000000..d7f99a3dab7 --- /dev/null +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ModelCapabilities } from "@t3tools/contracts"; + +import { + applyProviderOptionMenuEvent, + buildProviderOptionMenuActions, + providerOptionsConfigurationLabel, + resolveProviderOptionDescriptors, +} from "./providerOptions"; + +const CODEX_CAPABILITIES: ModelCapabilities = { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "medium", + }, + { + id: "serviceTier", + label: "Service Tier", + type: "select", + options: [ + { id: "default", label: "Standard", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + currentValue: "default", + }, + ], +}; + +describe("mobile provider options", () => { + it("renders the option descriptors advertised by the selected model", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + + expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ + { + title: "Reasoning", + subtitle: "Medium", + subactions: [ + { title: "Medium (default)", state: "on" }, + { title: "High", state: undefined }, + ], + }, + { + title: "Service Tier", + subtitle: "Standard", + subactions: [ + { title: "Standard (default)", state: "on" }, + { title: "Fast", state: undefined }, + ], + }, + ]); + expect(providerOptionsConfigurationLabel(descriptors)).toBe("Medium · Standard"); + }); + + it("updates generic select options without knowing provider-specific ids", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: CODEX_CAPABILITIES, + selections: undefined, + }); + const actions = buildProviderOptionMenuActions(descriptors); + const fastEvent = actions[1]?.subactions?.[1]?.id; + + expect(fastEvent).toBeDefined(); + expect(applyProviderOptionMenuEvent(descriptors, fastEvent!)).toEqual([ + { id: "reasoningEffort", value: "medium" }, + { id: "serviceTier", value: "priority" }, + ]); + }); + + it("treats an unspecified boolean capability as off", () => { + const descriptors = resolveProviderOptionDescriptors({ + capabilities: { + optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], + }, + selections: undefined, + }); + + expect(buildProviderOptionMenuActions(descriptors)).toMatchObject([ + { + title: "Fast Mode", + subtitle: "Off", + subactions: [ + { title: "Off", state: "on" }, + { title: "On", state: undefined }, + ], + }, + ]); + expect(providerOptionsConfigurationLabel(descriptors)).toBe("Configuration"); + }); +}); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts new file mode 100644 index 00000000000..ae195498962 --- /dev/null +++ b/apps/mobile/src/lib/providerOptions.ts @@ -0,0 +1,141 @@ +import type { + ModelCapabilities, + ProviderOptionDescriptor, + ProviderOptionSelection, +} from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; +import { + buildProviderOptionSelectionsFromDescriptors, + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; + +const PROVIDER_OPTION_EVENT_PREFIX = "provider-option:"; + +function providerOptionEvent(id: string, value: string | boolean): string { + return `${PROVIDER_OPTION_EVENT_PREFIX}${encodeURIComponent(JSON.stringify({ id, value }))}`; +} + +function parseProviderOptionEvent( + event: string, +): { readonly id: string; readonly value: string | boolean } | null { + if (!event.startsWith(PROVIDER_OPTION_EVENT_PREFIX)) { + return null; + } + + try { + const parsed: unknown = JSON.parse( + decodeURIComponent(event.slice(PROVIDER_OPTION_EVENT_PREFIX.length)), + ); + if ( + typeof parsed === "object" && + parsed !== null && + "id" in parsed && + typeof parsed.id === "string" && + "value" in parsed && + (typeof parsed.value === "string" || typeof parsed.value === "boolean") + ) { + return { id: parsed.id, value: parsed.value }; + } + } catch { + return null; + } + + return null; +} + +export function resolveProviderOptionDescriptors(input: { + readonly capabilities: ModelCapabilities | null | undefined; + readonly selections: ReadonlyArray | null | undefined; +}): ReadonlyArray { + if (!input.capabilities) { + return []; + } + return getProviderOptionDescriptors({ + caps: input.capabilities, + selections: input.selections, + }); +} + +export function buildProviderOptionMenuActions( + descriptors: ReadonlyArray, +): ReadonlyArray { + return descriptors.map((descriptor) => { + const currentValue = + descriptor.type === "boolean" + ? (descriptor.currentValue ?? false) + : getProviderOptionCurrentValue(descriptor); + const choices = + descriptor.type === "select" + ? descriptor.options.map((option) => ({ + id: providerOptionEvent(descriptor.id, option.id), + title: `${option.label}${option.isDefault ? " (default)" : ""}`, + state: currentValue === option.id ? ("on" as const) : undefined, + })) + : ([false, true] as const).map((value) => ({ + id: providerOptionEvent(descriptor.id, value), + title: value ? "On" : "Off", + state: currentValue === value ? ("on" as const) : undefined, + })); + + return { + id: `provider-option-menu:${descriptor.id}`, + title: descriptor.label, + subtitle: + descriptor.type === "boolean" + ? currentValue + ? "On" + : "Off" + : getProviderOptionCurrentLabel(descriptor), + subactions: choices, + }; + }); +} + +export function providerOptionsConfigurationLabel( + descriptors: ReadonlyArray, +): string { + const labels = descriptors.flatMap((descriptor) => { + if (descriptor.type === "boolean") { + return descriptor.currentValue ? [descriptor.label] : []; + } + const label = getProviderOptionCurrentLabel(descriptor); + return label ? [label] : []; + }); + return labels.length > 0 ? labels.join(" · ") : "Configuration"; +} + +export function applyProviderOptionMenuEvent( + descriptors: ReadonlyArray, + event: string, +): ReadonlyArray | null { + const selection = parseProviderOptionEvent(event); + if (!selection) { + return null; + } + + const descriptor = descriptors.find((candidate) => candidate.id === selection.id); + if (!descriptor) { + return null; + } + if ( + (descriptor.type === "boolean" && typeof selection.value !== "boolean") || + (descriptor.type === "select" && + (typeof selection.value !== "string" || + !descriptor.options.some((option) => option.id === selection.value))) + ) { + return null; + } + + const nextDescriptors = descriptors.map((candidate) => + candidate.id === descriptor.id + ? { + ...candidate, + currentValue: selection.value, + } + : candidate, + ) as ReadonlyArray; + + return buildProviderOptionSelectionsFromDescriptors(nextDescriptors) ?? []; +} diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index 191afe03c18..8cea5df2307 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -3,15 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { groupProjectsByRepository } from "./repositoryGroups"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; function makeProject( - input: Partial & - Pick, -): EnvironmentScopedProjectShell { + input: Partial & Pick, +): EnvironmentProject { return { workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, @@ -24,12 +20,9 @@ function makeProject( } function makeThread( - input: Partial & - Pick< - EnvironmentScopedThreadShell, - "environmentId" | "id" | "projectId" | "title" | "modelSelection" - >, -): EnvironmentScopedThreadShell { + input: Partial & + Pick, +): EnvironmentThreadShell { return { runtimeMode: "full-access", interactionMode: "default", diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts index 5238411a643..bf4c2f3fccd 100644 --- a/apps/mobile/src/lib/repositoryGroups.ts +++ b/apps/mobile/src/lib/repositoryGroups.ts @@ -3,21 +3,18 @@ import * as Arr from "effect/Array"; import type { RepositoryIdentity } from "@t3tools/contracts"; import { scopedProjectKey } from "./scopedEntities"; -import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, -} from "@t3tools/client-runtime"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; const DateDescending = Order.flip(Order.Date); -export interface MobileRepositoryProjectGroup { +export interface RepositoryProjectGroup { readonly key: string; - readonly project: EnvironmentScopedProjectShell; - readonly threads: ReadonlyArray; + readonly project: EnvironmentProject; + readonly threads: ReadonlyArray; readonly latestActivityAt: string; } -export interface MobileRepositoryGroup { +export interface RepositoryGroup { readonly key: string; readonly title: string; readonly subtitle: string | null; @@ -25,20 +22,20 @@ export interface MobileRepositoryGroup { readonly projectCount: number; readonly threadCount: number; readonly latestActivityAt: string; - readonly projects: ReadonlyArray; + readonly projects: ReadonlyArray; } function compareIsoDateDescending(left: string, right: string): number { return new Date(right).getTime() - new Date(left).getTime(); } -function deriveRepositoryGroupKey(project: EnvironmentScopedProjectShell): string { +function deriveRepositoryGroupKey(project: EnvironmentProject): string { return ( project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) ); } -function deriveRepositoryTitle(project: EnvironmentScopedProjectShell): string { +function deriveRepositoryTitle(project: EnvironmentProject): string { const identity = project.repositoryIdentity; return identity?.displayName ?? identity?.name ?? project.title; } @@ -54,18 +51,18 @@ function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefine } function deriveProjectLatestActivity( - project: EnvironmentScopedProjectShell, - threads: ReadonlyArray, + project: EnvironmentProject, + threads: ReadonlyArray, ): string { const latestThread = threads[0]; return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; } export function groupProjectsByRepository(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; -}): ReadonlyArray { - const threadsByProjectKey = new Map(); + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): ReadonlyArray { + const threadsByProjectKey = new Map(); for (const thread of input.threads) { const key = scopedProjectKey(thread.environmentId, thread.projectId); @@ -77,7 +74,7 @@ export function groupProjectsByRepository(input: { } } - const grouped = new Map(); + const grouped = new Map(); for (const project of input.projects) { const key = deriveRepositoryGroupKey(project); @@ -89,7 +86,7 @@ export function groupProjectsByRepository(input: { ); const latestActivityAt = deriveProjectLatestActivity(project, threads); - const projectGroup: MobileRepositoryProjectGroup = { + const projectGroup: RepositoryProjectGroup = { key: projectKey, project, threads, diff --git a/apps/mobile/src/lib/routes.ts b/apps/mobile/src/lib/routes.ts index bf49a20ac41..56d5663212c 100644 --- a/apps/mobile/src/lib/routes.ts +++ b/apps/mobile/src/lib/routes.ts @@ -1,5 +1,5 @@ import type { Href, useRouter } from "expo-router"; -import type { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; +import { type EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import type { SelectedThreadRef } from "../state/remote-runtime-types"; @@ -8,7 +8,7 @@ type Router = ReturnType; type ThreadRouteInput = | Pick - | Pick; + | Pick; type PlainThreadRouteInput = | { environmentId: EnvironmentId; diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index ce37a41e8ab..bb8c1e8398a 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -1,25 +1,29 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Socket from "effect/unstable/socket/Socket"; -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; -import { mobileCryptoLayer } from "../features/cloud/dpop"; -import { mobileManagedRelayClientLayer } from "../features/cloud/managedRelayLayer"; +import { cryptoLayer } from "../features/cloud/dpop"; +import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; -import { mobileTracingLayer } from "../features/observability/mobileTracing"; +import { tracingLayer } from "../features/observability/tracing"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; } -const mobileHttpClientLayer = remoteHttpClientLayer(fetch); +const httpClientLayer = remoteHttpClientLayer(fetch); -export const mobileRuntime = ManagedRuntime.make( - mobileManagedRelayClientLayer(configuredRelayUrl()).pipe( - Layer.provideMerge(mobileCryptoLayer), - Layer.provideMerge(mobileHttpClientLayer), - Layer.provideMerge(mobileTracingLayer.pipe(Layer.provide(mobileHttpClientLayer))), - ), +export const runtimeLayer = Layer.merge( + managedRelayClientLayer(configuredRelayUrl()), + Socket.layerWebSocketConstructorGlobal, +).pipe( + Layer.provideMerge(cryptoLayer), + Layer.provideMerge(httpClientLayer), + Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), ); -export const mobileRuntimeContextLayer = Layer.effectContext(mobileRuntime.contextEffect); +export const runtime = ManagedRuntime.make(runtimeLayer); + +export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 83ff2db5748..c3dd28ac3a1 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -25,7 +25,7 @@ vi.mock("react-native", () => ({ })); vi.mock("./runtime", () => ({ - mobileRuntime: { + runtime: { runPromise: vi.fn(), }, })); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 2f9e4962c1a..da54f92949b 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -1,9 +1,7 @@ import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; -import { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { isRelayManagedConnection, @@ -14,29 +12,12 @@ import { const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; -const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; - -export interface CachedShellSnapshot { - readonly schemaVersion: typeof SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION; - readonly environmentId: EnvironmentId; - readonly snapshotReceivedAt: string; - readonly snapshot: OrchestrationShellSnapshot; -} -export interface MobilePreferences { +export interface Preferences { readonly liveActivitiesEnabled?: boolean; readonly terminalFontSize?: number; } -const CachedShellSnapshotSchema = Schema.Struct({ - schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - snapshotReceivedAt: Schema.String, - snapshot: OrchestrationShellSnapshot, -}); -const decodeCachedShellSnapshot = Schema.decodeUnknownOption(CachedShellSnapshotSchema); - async function readStorageItem(key: string): Promise { return await SecureStore.getItemAsync(key); } @@ -58,77 +39,6 @@ async function readJsonStorageItem(key: string): Promise { } } -function cachedShellSnapshotFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.json`; -} - -async function getShellSnapshotCacheDirectory() { - const { Directory, Paths } = await import("expo-file-system"); - const directory = new Directory(Paths.document, SHELL_SNAPSHOT_CACHE_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); - return directory; -} - -export async function loadCachedShellSnapshot( - environmentId: EnvironmentId, -): Promise { - try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - if (!file.exists) { - return null; - } - - const parsed = JSON.parse(await file.text()) as unknown; - const decoded = decodeCachedShellSnapshot(parsed); - if (Option.isNone(decoded) || decoded.value.environmentId !== environmentId) { - return null; - } - - return decoded.value; - } catch { - return null; - } -} - -export async function saveCachedShellSnapshot( - environmentId: EnvironmentId, - snapshot: OrchestrationShellSnapshot, -): Promise { - try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - const document: CachedShellSnapshot = { - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshotReceivedAt: new Date().toISOString(), - snapshot, - }; - - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(document)); - } catch { - // Cache persistence is best-effort and should never block live data. - } -} - -export async function clearCachedShellSnapshot(environmentId: EnvironmentId): Promise { - try { - const { File } = await import("expo-file-system"); - const directory = await getShellSnapshotCacheDirectory(); - const file = new File(directory, cachedShellSnapshotFileName(environmentId)); - if (file.exists) { - file.delete(); - } - } catch { - // Ignore cache cleanup failures. - } -} - export async function loadSavedConnections(): Promise> { const parsed = await readJsonStorageItem<{ readonly connections?: ReadonlyArray; @@ -169,8 +79,8 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis await writeStorageItem(CONNECTIONS_KEY, JSON.stringify({ connections: next })); } -export async function loadPreferences(): Promise { - const parsed = await readJsonStorageItem(PREFERENCES_KEY); +export async function loadPreferences(): Promise { + const parsed = await readJsonStorageItem(PREFERENCES_KEY); if (!parsed || typeof parsed !== "object") { return {}; } @@ -190,11 +100,9 @@ export async function loadPreferences(): Promise { return preferences; } -export async function savePreferencesPatch( - patch: Partial, -): Promise { +export async function savePreferencesPatch(patch: Partial): Promise { const current = await loadPreferences(); - const next: MobilePreferences = { + const next: Preferences = { ...current, ...patch, }; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 94354df744e..b500752c5d9 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { EventId, + MessageId, ProjectId, ProviderInstanceId, ThreadId, @@ -10,7 +11,7 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; -import { buildThreadFeed } from "./threadActivity"; +import { buildThreadFeed, deriveThreadFeedPresentation } from "./threadActivity"; function makeActivity( input: Partial & @@ -48,7 +49,7 @@ function makeThread( } describe("buildThreadFeed", () => { - it("includes runtime warnings from the latest turn", () => { + it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ id: ThreadId.make("thread-1"), projectId: ProjectId.make("project-1"), @@ -86,22 +87,16 @@ describe("buildThreadFeed", () => { }); const feed = buildThreadFeed(thread, [], null); - const group = feed[0]; - - expect(group).toMatchObject({ - type: "activity-group", - }); - if (!group || group.type !== "activity-group") { - return; - } - - expect(group.activities).toEqual([ + expect(feed).toMatchObject([ + { + type: "activity-group", + turnId: "turn-old", + activities: [{ id: "activity-old", turnId: "turn-old" }], + }, { - id: "activity-latest", - createdAt: "2026-04-01T00:00:03.000Z", - summary: "Runtime warning", - detail: null, - status: null, + type: "activity-group", + turnId: "turn-latest", + activities: [{ id: "activity-latest", turnId: "turn-latest" }], }, ]); }); @@ -163,10 +158,201 @@ describe("buildThreadFeed", () => { { id: "tool-completed", createdAt: "2026-04-01T00:00:02.000Z", + turnId: "turn-1", summary: "Run tests", detail: "bun run test", - status: null, + fullDetail: null, + copyText: "Run tests\nbun run test", + toolLike: true, + status: "success", }, ]); }); + + it("folds settled turn work while leaving the terminal answer visible", () => { + const turnId = TurnId.make("turn-1"); + const thread = makeThread({ + id: ThreadId.make("thread-3"), + projectId: ProjectId.make("project-1"), + title: "Folded work", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: "2026-04-01T00:00:18.000Z", + assistantMessageId: MessageId.make("assistant-final"), + }, + messages: [ + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "I am checking.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:02.000Z", + updatedAt: "2026-04-01T00:00:03.000Z", + }, + { + id: MessageId.make("assistant-final"), + role: "assistant", + text: "Done.", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:18.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("tool-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read files", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Read files", + itemType: "file_read", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.map((entry) => entry.id)).toEqual(["turn-fold:turn-1", "assistant-final"]); + expect(collapsed[0]).toMatchObject({ + type: "turn-fold", + label: "Worked for 17s", + expanded: false, + }); + + const expanded = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set([turnId])); + expect(expanded.map((entry) => entry.id)).toEqual([ + "turn-fold:turn-1", + "assistant-commentary", + "tool-completed", + "assistant-final", + ]); + }); + + it("measures a steer-superseded turn from its user boundary through trailing work", () => { + const firstTurnId = TurnId.make("turn-1"); + const secondTurnId = TurnId.make("turn-2"); + const thread = makeThread({ + id: ThreadId.make("thread-steered"), + projectId: ProjectId.make("project-1"), + title: "Steered work", + latestTurn: { + turnId: secondTurnId, + state: "running", + requestedAt: "2026-04-01T00:00:14.000Z", + startedAt: "2026-04-01T00:00:14.000Z", + completedAt: null, + assistantMessageId: MessageId.make("assistant-next"), + }, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "Do it once more.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + { + id: MessageId.make("assistant-commentary"), + role: "assistant", + text: "Kicking off call 1.", + turnId: firstTurnId, + streaming: false, + createdAt: "2026-04-01T00:00:09.000Z", + updatedAt: "2026-04-01T00:00:09.000Z", + }, + { + id: MessageId.make("user-2"), + role: "user", + text: "Actually do 15.", + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:14.000Z", + updatedAt: "2026-04-01T00:00:14.000Z", + }, + { + id: MessageId.make("assistant-next"), + role: "assistant", + text: "One down - adjusting.", + turnId: secondTurnId, + streaming: true, + createdAt: "2026-04-01T00:00:17.000Z", + updatedAt: "2026-04-01T00:00:17.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("work-1"), + kind: "tool.completed", + tone: "tool", + summary: "Ran command", + createdAt: "2026-04-01T00:00:12.000Z", + turnId: firstTurnId, + payload: { + title: "Ran command", + itemType: "command_execution", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + const collapsed = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(collapsed.find((entry) => entry.type === "turn-fold")).toMatchObject({ + turnId: firstTurnId, + label: "Worked for 12s", + }); + }); + + it("keeps an active turn expanded and classifies error-shaped tool output", () => { + const turnId = TurnId.make("turn-running"); + const thread = makeThread({ + id: ThreadId.make("thread-4"), + projectId: ProjectId.make("project-1"), + title: "Running work", + latestTurn: { + turnId, + state: "running", + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:01.000Z", + completedAt: null, + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("tool-failed"), + kind: "tool.completed", + tone: "tool", + summary: "Run command", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + title: "Run command", + itemType: "command_execution", + detail: "zsh: command not found: nope", + status: "completed", + }, + }), + ], + }); + + const feed = buildThreadFeed(thread, [], null); + expect(deriveThreadFeedPresentation(feed, thread.latestTurn, new Set())).toEqual(feed); + expect(feed[0]).toMatchObject({ + type: "activity-group", + activities: [{ status: "failure" }], + }); + }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 6ff27cadfee..088186d4df4 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,17 +1,16 @@ import { ApprovalRequestId, isToolLifecycleItemType } from "@t3tools/contracts"; import type { - CommandId, - EnvironmentId, MessageId, + OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, - TurnId, ToolLifecycleItemType, - ThreadId, + TurnId, UserInputQuestion, } from "@t3tools/contracts"; +import { formatDuration } from "@t3tools/shared/orchestrationTiming"; -import type { DraftComposerImageAttachment } from "./composerImages"; +import type { QueuedThreadMessage } from "../state/thread-outbox"; import * as Arr from "effect/Array"; import * as Order from "effect/Order"; @@ -33,27 +32,24 @@ export interface PendingUserInputDraftAnswer { readonly customAnswer?: string; } -export interface QueuedThreadMessage { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly messageId: MessageId; - readonly commandId: CommandId; - readonly text: string; - readonly attachments: ReadonlyArray; - readonly createdAt: string; -} - export interface ThreadFeedActivity { readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly summary: string; readonly detail: string | null; - readonly status: string | null; + readonly fullDetail: string | null; + readonly copyText: string; + readonly toolLike: boolean; + readonly status: "success" | "failure" | "neutral" | null; } +type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; + interface WorkLogEntry { id: string; createdAt: string; + turnId: TurnId | null; label: string; detail?: string; command?: string; @@ -63,6 +59,7 @@ interface WorkLogEntry { toolTitle?: string; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; + toolLifecycleStatus?: WorkLogToolLifecycleStatus; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -88,6 +85,7 @@ type RawThreadFeedEntry = readonly type: "activity"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activity: ThreadFeedActivity; }; @@ -97,9 +95,23 @@ export type ThreadFeedEntry = readonly type: "activity-group"; readonly id: string; readonly createdAt: string; + readonly turnId: TurnId | null; readonly activities: ReadonlyArray; + } + | { + readonly type: "turn-fold"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId; + readonly label: string; + readonly expanded: boolean; }; +export type ThreadFeedLatestTurn = Pick< + OrchestrationLatestTurn, + "turnId" | "state" | "startedAt" | "completedAt" +>; + function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { switch (requestType) { case "command_execution_approval": @@ -202,14 +214,12 @@ function resolvePendingUserInputAnswer( function deriveWorkLogEntries( activities: ReadonlyArray, - latestTurnId: TurnId | undefined, ): WorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { - if (latestTurnId && activity.turnId !== latestTurnId) continue; if (activity.kind === "tool.started") continue; - if (activity.kind === "task.started" || activity.kind === "task.completed") continue; + if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; @@ -240,16 +250,40 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; + const taskSummary = + isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 + ? payload.summary + : null; + const taskDetailAsLabel = + isTaskActivity && + !taskSummary && + typeof payload?.detail === "string" && + payload.detail.length > 0 + ? payload.detail + : null; + const taskLabel = taskSummary || taskDetailAsLabel; const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, - label: activity.summary, - tone: activity.tone === "approval" ? "info" : activity.tone, + turnId: activity.turnId, + label: taskLabel || activity.summary, + tone: + activity.kind === "task.progress" + ? "thinking" + : activity.tone === "approval" + ? "info" + : activity.tone, activityKind: activity.kind, }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { + if ( + !taskDetailAsLabel && + payload && + typeof payload.detail === "string" && + payload.detail.length > 0 + ) { const detail = stripTrailingExitCode(payload.detail).output; if (detail) { entry.detail = detail; @@ -273,6 +307,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); + if (!toolLifecycleStatus && activity.kind === "tool.completed") { + toolLifecycleStatus = "completed"; + } + if (toolLifecycleStatus) { + entry.toolLifecycleStatus = toolLifecycleStatus; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -323,6 +364,7 @@ function mergeDerivedWorkLogEntries( const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; + const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; return { ...previous, ...next, @@ -334,6 +376,7 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolLifecycleStatus ? { toolLifecycleStatus } : {}), }; } @@ -365,6 +408,78 @@ function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { + if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { + return true; + } + if (entry.command !== undefined && entry.command.trim().length > 0) { + return true; + } + if (entry.requestKind !== undefined) { + return true; + } + return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); +} + +function toolDetailTextLooksLikeFailure(text: string): boolean { + const normalized = text.toLowerCase(); + return ( + normalized.includes("file not found") || + normalized.includes("no files found") || + normalized.includes("enoent") || + normalized.includes("no such file or directory") || + normalized.includes("no such file") || + normalized.includes("commandnotfoundexception") || + normalized.includes("command not found") || + (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || + (normalized.includes("is not recognized") && normalized.includes("the term '")) || + //i.test(text) || + /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || + /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) + ); +} + +function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { + if (entry.tone === "error") { + return true; + } + if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { + return true; + } + if (!workLogEntryIsToolLike(entry)) { + return false; + } + return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); +} + +function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { + if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { + return false; + } + if (entry.tone === "thinking") { + return false; + } + return ( + entry.toolLifecycleStatus !== "inProgress" && + entry.toolLifecycleStatus !== "stopped" && + entry.toolLifecycleStatus !== "failed" && + entry.toolLifecycleStatus !== "declined" + ); +} + +function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { + if (!workLogEntryIsToolLike(entry)) { + return null; + } + if (workEntryIndicatesToolFailure(entry)) { + return "failure"; + } + if (workEntryIndicatesToolSuccess(entry)) { + return "success"; + } + return "neutral"; +} + function workEntryPreview( workEntry: Pick, ): string | null { @@ -592,6 +707,22 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } +function extractWorkLogToolLifecycleStatus( + payload: Record | null, +): WorkLogToolLifecycleStatus | undefined { + const status = payload?.status; + if ( + status === "inProgress" || + status === "completed" || + status === "failed" || + status === "declined" || + status === "stopped" + ) { + return status; + } + return undefined; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; @@ -743,7 +874,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th } const previous = grouped.at(-1); - if (previous?.type === "activity-group") { + if (previous?.type === "activity-group" && previous.turnId === entry.turnId) { grouped[grouped.length - 1] = { ...previous, activities: [...previous.activities, entry.activity], @@ -755,6 +886,7 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th type: "activity-group", id: entry.id, createdAt: entry.createdAt, + turnId: entry.turnId, activities: [entry.activity], }); } @@ -762,6 +894,179 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th return grouped; } +function computeElapsedMs(startIso: string, endIso: string): number | null { + const start = Date.parse(startIso); + const end = Date.parse(endIso); + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null; + } + return Math.max(0, end - start); +} + +function maxIsoTimestamp(a: string | null, b: string | null): string | null { + if (a === null) return b; + if (b === null) return a; + const aMs = Date.parse(a); + const bMs = Date.parse(b); + if (!Number.isFinite(aMs)) return b; + if (!Number.isFinite(bMs)) return a; + return bMs > aMs ? b : a; +} + +function deriveUnsettledTurnId(latestTurn: ThreadFeedLatestTurn | null): TurnId | null { + if (!latestTurn) { + return null; + } + const settled = latestTurn.completedAt !== null && latestTurn.state !== "running"; + return settled ? null : latestTurn.turnId; +} + +interface ThreadFeedTurnFold { + readonly turnId: TurnId; + readonly createdAt: string; + readonly hiddenEntryIds: ReadonlySet; + readonly label: string; +} + +function deriveThreadFeedTurnFolds( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, +): ReadonlyMap { + const terminalAssistantMessageIdByTurn = new Map(); + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "assistant" && entry.message.turnId) { + terminalAssistantMessageIdByTurn.set(entry.message.turnId, entry.id); + } + } + + interface TurnGroup { + readonly entries: ThreadFeedEntry[]; + readonly startBoundary: string | null; + } + const groupsByTurnId = new Map(); + let pendingUserBoundary: string | null = null; + for (const entry of feed) { + if (entry.type === "message" && entry.message.role === "user") { + pendingUserBoundary = entry.message.createdAt; + continue; + } + const turnId = + entry.type === "message" && entry.message.role === "assistant" + ? entry.message.turnId + : entry.type === "activity-group" + ? entry.turnId + : null; + if (!turnId) { + continue; + } + let group = groupsByTurnId.get(turnId); + if (!group) { + group = { + entries: [], + startBoundary: pendingUserBoundary, + }; + pendingUserBoundary = null; + groupsByTurnId.set(turnId, group); + } + group.entries.push(entry); + } + + const unsettledTurnId = deriveUnsettledTurnId(latestTurn); + const foldsByAnchorId = new Map(); + for (const [turnId, group] of groupsByTurnId) { + const { entries } = group; + if (turnId === unsettledTurnId) { + continue; + } + if (entries.some((entry) => entry.type === "message" && entry.message.streaming)) { + continue; + } + + const terminalAssistantMessageId = terminalAssistantMessageIdByTurn.get(turnId); + const hiddenEntryIds = new Set( + entries.filter((entry) => entry.id !== terminalAssistantMessageId).map((entry) => entry.id), + ); + if (hiddenEntryIds.size === 0) { + continue; + } + + const firstEntry = entries[0]; + const lastEntry = entries.at(-1); + if (!firstEntry || !lastEntry) { + continue; + } + const terminalEntry = terminalAssistantMessageId + ? entries.find((entry) => entry.id === terminalAssistantMessageId) + : null; + const latestTurnMatches = latestTurn?.turnId === turnId; + const lastEntryEnd = + lastEntry.type === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; + const elapsedMs = + latestTurnMatches && latestTurn.startedAt && latestTurn.completedAt + ? computeElapsedMs(latestTurn.startedAt, latestTurn.completedAt) + : computeElapsedMs( + group.startBoundary ?? firstEntry.createdAt, + maxIsoTimestamp( + terminalEntry?.type === "message" ? terminalEntry.message.updatedAt : null, + lastEntryEnd, + ) ?? lastEntryEnd, + ); + const duration = elapsedMs === null ? null : formatDuration(elapsedMs); + const interrupted = latestTurnMatches && latestTurn.state === "interrupted"; + const label = interrupted + ? duration + ? `You stopped after ${duration}` + : "You stopped this response" + : duration + ? `Worked for ${duration}` + : "Worked"; + + foldsByAnchorId.set(firstEntry.id, { + turnId, + createdAt: firstEntry.createdAt, + hiddenEntryIds, + label, + }); + } + return foldsByAnchorId; +} + +export function deriveThreadFeedPresentation( + feed: ReadonlyArray, + latestTurn: ThreadFeedLatestTurn | null, + expandedTurnIds: ReadonlySet, +): ThreadFeedEntry[] { + const sourceFeed = feed.filter((entry) => entry.type !== "turn-fold"); + const foldsByAnchorId = deriveThreadFeedTurnFolds(sourceFeed, latestTurn); + const collapsedEntryIds = new Set(); + for (const fold of foldsByAnchorId.values()) { + if (!expandedTurnIds.has(fold.turnId)) { + for (const entryId of fold.hiddenEntryIds) { + collapsedEntryIds.add(entryId); + } + } + } + + const result: ThreadFeedEntry[] = []; + for (const entry of sourceFeed) { + const fold = foldsByAnchorId.get(entry.id); + if (fold) { + result.push({ + type: "turn-fold", + id: `turn-fold:${fold.turnId}`, + createdAt: fold.createdAt, + turnId: fold.turnId, + label: fold.label, + expanded: expandedTurnIds.has(fold.turnId), + }); + } + if (!collapsedEntryIds.has(entry.id)) { + result.push(entry); + } + } + return result; +} + export function derivePendingApprovals( activities: ReadonlyArray, ): PendingApproval[] { @@ -893,10 +1198,7 @@ export function buildThreadFeed( const loadedMessages = options?.loadedMessages ?? thread.messages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; - const workLogEntries = deriveWorkLogEntries( - thread.activities, - thread.latestTurn?.turnId ?? undefined, - ); + const workLogEntries = deriveWorkLogEntries(thread.activities); const entries = Arr.sortWith( [ ...loadedMessages.map((message) => ({ @@ -921,18 +1223,36 @@ export function buildThreadFeed( oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt ); }) - .map((entry) => ({ - type: "activity", - id: entry.id, - createdAt: entry.createdAt, - activity: { + .map((entry) => { + const summary = workEntryHeading(entry); + const detail = workEntryPreview(entry); + const normalizedFullDetail = entry.detail + ? unwrapKnownShellCommandWrapper(entry.detail) + : null; + const fullDetail = + normalizedFullDetail && normalizedFullDetail !== detail ? normalizedFullDetail : null; + return { + type: "activity", id: entry.id, createdAt: entry.createdAt, - summary: workEntryHeading(entry), - detail: workEntryPreview(entry), - status: null, - }, - })), + turnId: entry.turnId, + activity: { + id: entry.id, + createdAt: entry.createdAt, + turnId: entry.turnId, + summary, + detail, + fullDetail, + copyText: [summary, detail, fullDetail] + .filter((value, index, values): value is string => { + return Boolean(value) && values.indexOf(value) === index; + }) + .join("\n"), + toolLike: workLogEntryIsToolLike(entry), + status: workEntryStatus(entry), + }, + }; + }), ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/mobile/src/lib/threadFeedLayout.test.ts b/apps/mobile/src/lib/threadFeedLayout.test.ts new file mode 100644 index 00000000000..73f113eac38 --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isThreadFeedNearEnd, + resolveThreadFeedBottomInset, + threadFeedDistanceFromEnd, +} from "./threadFeedLayout"; + +describe("thread feed layout", () => { + it("accounts for the bottom inset when measuring distance from the end", () => { + const metrics = { + contentHeight: 900, + viewportHeight: 600, + offsetY: 380, + bottomInset: 100, + }; + + expect(threadFeedDistanceFromEnd(metrics)).toBe(20); + expect(isThreadFeedNearEnd(metrics, 50)).toBe(true); + expect(isThreadFeedNearEnd(metrics, 10)).toBe(false); + }); + + it("does not double count chrome already included in the measured composer overlay", () => { + expect( + resolveThreadFeedBottomInset({ + estimatedOverlayHeight: 162, + measuredOverlayHeight: 182, + gap: 8, + }), + ).toBe(190); + }); +}); diff --git a/apps/mobile/src/lib/threadFeedLayout.ts b/apps/mobile/src/lib/threadFeedLayout.ts new file mode 100644 index 00000000000..de7946f866d --- /dev/null +++ b/apps/mobile/src/lib/threadFeedLayout.ts @@ -0,0 +1,22 @@ +export interface ThreadFeedScrollMetrics { + readonly contentHeight: number; + readonly viewportHeight: number; + readonly offsetY: number; + readonly bottomInset: number; +} + +export function threadFeedDistanceFromEnd(metrics: ThreadFeedScrollMetrics): number { + return metrics.contentHeight + metrics.bottomInset - metrics.viewportHeight - metrics.offsetY; +} + +export function isThreadFeedNearEnd(metrics: ThreadFeedScrollMetrics, threshold: number): boolean { + return threadFeedDistanceFromEnd(metrics) <= threshold; +} + +export function resolveThreadFeedBottomInset(input: { + readonly estimatedOverlayHeight: number; + readonly measuredOverlayHeight: number; + readonly gap: number; +}): number { + return Math.max(input.estimatedOverlayHeight, input.measuredOverlayHeight) + input.gap; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.ios.tsx b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx new file mode 100644 index 00000000000..488766f3695 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.ios.tsx @@ -0,0 +1,21 @@ +import { + SelectableMarkdownText as T3SelectableMarkdownText, + type SelectableMarkdownTextProps, +} from "@t3tools/mobile-markdown-text/renderer"; + +import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return true; +} + +export function SelectableMarkdownText(props: MobileSelectableMarkdownTextProps) { + return ; +} diff --git a/apps/mobile/src/native/SelectableMarkdownText.tsx b/apps/mobile/src/native/SelectableMarkdownText.tsx new file mode 100644 index 00000000000..403f32a1de4 --- /dev/null +++ b/apps/mobile/src/native/SelectableMarkdownText.tsx @@ -0,0 +1,16 @@ +import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/renderer"; + +type MobileSelectableMarkdownTextProps = Omit; + +export type { + NativeMarkdownTextStyle, + SelectableMarkdownSkill, +} from "@t3tools/mobile-markdown-text/types"; + +export function hasNativeSelectableMarkdownText(): boolean { + return false; +} + +export function SelectableMarkdownText(_props: MobileSelectableMarkdownTextProps) { + return null; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx new file mode 100644 index 00000000000..04cabdaa7cf --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -0,0 +1,179 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { requireNativeView } from "expo"; +import { useImperativeHandle, useMemo, useRef, type Ref } from "react"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; +import { Image, StyleSheet } from "react-native"; + +import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { useThemeColor } from "../lib/useThemeColor"; +import type { ComposerEditorProps, ComposerEditorSelection } from "./T3ComposerEditor.types"; + +const NATIVE_MODULE_NAME = "T3ComposerEditor"; + +type NativeEditorEvent = NativeSyntheticEvent<{ + readonly value: string; + readonly selection: ComposerEditorSelection; +}>; + +type NativeSelectionEvent = NativeSyntheticEvent<{ + readonly selection: ComposerEditorSelection; +}>; + +type NativePasteImagesEvent = NativeSyntheticEvent<{ + readonly uris: ReadonlyArray; +}>; + +interface NativeComposerEditorRef { + focus: () => Promise; + blur: () => Promise; + setSelection: (start: number, end: number) => Promise; +} + +interface NativeComposerEditorProps extends ViewProps { + readonly ref?: Ref; + readonly value: string; + readonly tokensJson: string; + readonly selectionJson: string; + readonly themeJson: string; + readonly placeholder: string; + readonly fontFamily: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly contentInsetVertical: number; + readonly editable: boolean; + readonly scrollEnabled: boolean; + readonly autoFocus: boolean; + readonly autoCorrect: boolean; + readonly spellCheck: boolean; + readonly onComposerChange: (event: NativeEditorEvent) => void; + readonly onComposerSelectionChange?: (event: NativeSelectionEvent) => void; + readonly onComposerPasteImages?: (event: NativePasteImagesEvent) => void; + readonly onComposerFocus?: () => void; + readonly onComposerBlur?: () => void; +} + +const NativeView = requireNativeView(NATIVE_MODULE_NAME); + +function basename(path: string): string { + const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separator >= 0 ? path.slice(separator + 1) : path; +} + +function fileIconUri(path: string): string { + return Image.resolveAssetSource(markdownFileIconSource(resolveMarkdownFileIcon(path))).uri; +} + +export function ComposerEditor({ + ref, + skills = [], + selection, + style, + textStyle, + onChangeText, + onSelectionChange, + onPasteImages, + onFocus, + onBlur, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const nativeRef = useRef(null); + const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value)); + const textColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const chipBackground = useThemeColor("--color-subtle"); + const chipBorder = useThemeColor("--color-border"); + const chipText = useThemeColor("--color-foreground"); + const skillBackground = useThemeColor("--color-inline-skill-background"); + const skillBorder = useThemeColor("--color-inline-skill-border"); + const skillText = useThemeColor("--color-inline-skill-foreground"); + const fileTint = useThemeColor("--color-icon-muted"); + + useImperativeHandle( + ref, + () => ({ + focus: () => void nativeRef.current?.focus(), + blur: () => void nativeRef.current?.blur(), + setSelection: (nextSelection) => + void nativeRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + const skillLabels = useMemo( + () => new Map(skills.map((skill) => [skill.name, skill.displayName?.trim() || skill.name])), + [skills], + ); + const tokensJson = useMemo(() => { + const tokens = collectComposerInlineTokens(props.value, { + preserveTrailingFrom: confirmedTokensRef.current, + }); + confirmedTokensRef.current = tokens; + return JSON.stringify( + tokens.map((token) => ({ + type: token.type, + source: token.source, + start: token.start, + end: token.end, + label: + token.type === "skill" + ? (skillLabels.get(token.value) ?? token.value) + : basename(token.value), + iconUri: token.type === "mention" ? fileIconUri(token.value) : null, + })), + ); + }, [props.value, skillLabels]); + const themeJson = JSON.stringify({ + text: String(textColor), + placeholder: String(placeholderColor), + chipBackground: String(chipBackground), + chipBorder: String(chipBorder), + chipText: String(chipText), + skillBackground: String(skillBackground), + skillBorder: String(skillBorder), + skillText: String(skillText), + fileTint: String(fileTint), + }); + const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; + return ( + } + onComposerChange={(event) => { + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + }} + onComposerSelectionChange={(event) => onSelectionChange?.(event.nativeEvent.selection)} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx new file mode 100644 index 00000000000..0f20e9e042d --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -0,0 +1,65 @@ +import { TextInputWrapper } from "expo-paste-input"; +import { useImperativeHandle, useRef } from "react"; +import { TextInput, type TextInput as RNTextInput } from "react-native"; + +import { useThemeColor } from "../lib/useThemeColor"; +import { useNativePaste } from "../lib/useNativePaste"; +import type { ComposerEditorProps } from "./T3ComposerEditor.types"; + +export function ComposerEditor({ + ref, + skills: _skills, + selection, + onPasteImages, + style, + textStyle, + contentInsetVertical = 0, + ...props +}: ComposerEditorProps) { + const inputRef = useRef(null); + const foregroundColor = useThemeColor("--color-foreground"); + const placeholderColor = useThemeColor("--color-placeholder"); + const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); + + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + setSelection: (nextSelection) => + inputRef.current?.setSelection(nextSelection.start, nextSelection.end), + }), + [], + ); + + return ( + + props.onSelectionChange?.(event.nativeEvent.selection)} + multiline={props.multiline ?? true} + placeholderTextColor={placeholderColor} + style={[ + { + flex: 1, + minHeight: 0, + color: foregroundColor, + fontFamily: "DMSans_400Regular", + fontSize: 15, + lineHeight: 22, + paddingVertical: contentInsetVertical, + }, + textStyle, + ]} + /> + + ); +} + +export type { + ComposerEditorHandle, + ComposerEditorProps, + ComposerEditorSelection, +} from "./T3ComposerEditor.types"; diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts new file mode 100644 index 00000000000..d70d63fa437 --- /dev/null +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -0,0 +1,38 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import type { Ref } from "react"; +import type { StyleProp, TextStyle, ViewStyle } from "react-native"; + +export type ComposerEditorSelection = { + readonly start: number; + readonly end: number; +}; + +export interface ComposerEditorHandle { + focus: () => void; + blur: () => void; + setSelection: (selection: ComposerEditorSelection) => void; +} + +export interface ComposerEditorProps { + readonly ref?: Ref; + readonly value: string; + readonly skills?: ReadonlyArray< + Pick + >; + readonly selection?: ComposerEditorSelection; + readonly placeholder?: string; + readonly autoFocus?: boolean; + readonly editable?: boolean; + readonly scrollEnabled?: boolean; + readonly autoCorrect?: boolean; + readonly spellCheck?: boolean; + readonly multiline?: boolean; + readonly contentInsetVertical?: number; + readonly style?: StyleProp; + readonly textStyle?: StyleProp; + readonly onChangeText: (value: string) => void; + readonly onSelectionChange?: (selection: ComposerEditorSelection) => void; + readonly onPasteImages?: (uris: ReadonlyArray) => void; + readonly onFocus?: () => void; + readonly onBlur?: () => void; +} diff --git a/apps/mobile/src/state/auth.ts b/apps/mobile/src/state/auth.ts new file mode 100644 index 00000000000..835dee7f783 --- /dev/null +++ b/apps/mobile/src/state/auth.ts @@ -0,0 +1,5 @@ +import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/cloud.ts b/apps/mobile/src/state/cloud.ts new file mode 100644 index 00000000000..a11fa1cb2e6 --- /dev/null +++ b/apps/mobile/src/state/cloud.ts @@ -0,0 +1,5 @@ +import { createCloudEnvironmentAtoms } from "@t3tools/client-runtime/state/cloud"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const cloudEnvironment = createCloudEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts new file mode 100644 index 00000000000..9eec5dc1250 --- /dev/null +++ b/apps/mobile/src/state/entities.ts @@ -0,0 +1,59 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { + EnvironmentId, + ScopedProjectRef, + ScopedThreadRef, + ServerConfig, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentProjects } from "./projects"; +import { environmentServerConfigsAtom } from "./server"; +import { environmentSession } from "./session"; +import { environmentThreadShells } from "./threads"; + +const EMPTY_PROJECT_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-project:empty"), +); +const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-thread-shell:empty"), +); +const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-server-config:empty"), +); + +export function useProjects(): ReadonlyArray { + return useAtomValue(environmentProjects.projectsAtom); +} + +export function useThreadShells(): ReadonlyArray { + return useAtomValue(environmentThreadShells.threadShellsAtom); +} + +export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { + return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); +} + +export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), + ); +} + +export function useEnvironmentServerConfig( + environmentId: EnvironmentId | null, +): ServerConfig | null { + return useAtomValue( + environmentId === null + ? EMPTY_SERVER_CONFIG_ATOM + : environmentSession.configValueAtom(environmentId), + ); +} + +export function useServerConfigs(): ReadonlyMap { + return useAtomValue(environmentServerConfigsAtom); +} diff --git a/apps/mobile/src/state/environment-session-registry.ts b/apps/mobile/src/state/environment-session-registry.ts deleted file mode 100644 index 3eb94b32c06..00000000000 --- a/apps/mobile/src/state/environment-session-registry.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; - -import type { EnvironmentSession } from "./remote-runtime-types"; - -const environmentSessions = new Map(); -const environmentConnectionListeners = new Set<() => void>(); - -export function getEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { - return environmentSessions.get(environmentId) ?? null; -} - -export function getEnvironmentClient(environmentId: EnvironmentId) { - return getEnvironmentSession(environmentId)?.client ?? null; -} - -export function setEnvironmentSession( - environmentId: EnvironmentId, - session: EnvironmentSession, -): void { - environmentSessions.set(environmentId, session); -} - -export function removeEnvironmentSession(environmentId: EnvironmentId): EnvironmentSession | null { - const session = getEnvironmentSession(environmentId); - environmentSessions.delete(environmentId); - return session; -} - -export function drainEnvironmentSessions(): ReadonlyArray { - const sessions = [...environmentSessions.values()]; - environmentSessions.clear(); - return sessions; -} - -export function notifyEnvironmentConnectionListeners() { - for (const listener of environmentConnectionListeners) listener(); -} - -/** - * Subscribe to environment-connection changes (connect / disconnect / reconnect). - * Returns an unsubscribe function. - */ -export function subscribeEnvironmentConnections(listener: () => void): () => void { - environmentConnectionListeners.add(listener); - return () => { - environmentConnectionListeners.delete(listener); - }; -} diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts new file mode 100644 index 00000000000..467660c1914 --- /dev/null +++ b/apps/mobile/src/state/environments.ts @@ -0,0 +1,130 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + type EnvironmentPresentation as BaseEnvironmentPresentation, +} from "@t3tools/client-runtime/connection"; +import { + RelayConnectionRegistration, + RelayConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { useCallback, useMemo } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { + connectPairingUrl as connectPairingUrlAtom, + updateBearerConnection, +} from "../connection/onboarding"; +import { environmentPresentations } from "./presentation"; +import { useEnvironmentQuery } from "./query"; +import { relayEnvironmentDiscovery } from "./relay"; + +export interface EnvironmentPresentation extends BaseEnvironmentPresentation { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly displayUrl: string | null; + readonly relayManaged: boolean; +} + +export function projectEnvironmentPresentation( + environmentId: EnvironmentId, + presentation: BaseEnvironmentPresentation, +): EnvironmentPresentation { + return { + ...presentation, + environmentId, + label: presentation.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(presentation.entry), + relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + }; +} + +export function useEnvironments() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); + const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + + const environments = useMemo( + () => + [...presentationById.entries()].map(([environmentId, presentation]) => + projectEnvironmentPresentation(environmentId, presentation), + ), + [presentationById], + ); + + return { + isReady: catalog.isReady, + networkStatus, + environments, + presentationById, + }; +} + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} + +export function useEnvironmentConnectionActions() { + const register = useAtomSet(environmentCatalog.register, { mode: "promise" }); + const remove = useAtomSet(environmentCatalog.remove, { mode: "promise" }); + const removeRelayEnvironments = useAtomSet(environmentCatalog.removeRelayEnvironments, { + mode: "promise", + }); + const retryNow = useAtomSet(environmentCatalog.retryNow, { mode: "promise" }); + + return useMemo( + () => ({ + register, + remove, + removeRelayEnvironments, + retryNow, + }), + [register, remove, removeRelayEnvironments, retryNow], + ); +} + +export function useEnvironmentActions() { + const connectPairingUrl = useAtomSet(connectPairingUrlAtom, { + mode: "promise", + }); + const updateBearer = useAtomSet(updateBearerConnection, { + mode: "promise", + }); + const { register, remove, retryNow } = useEnvironmentConnectionActions(); + const refreshRelayEnvironments = useAtomSet(relayEnvironmentDiscovery.refresh, { + mode: "promise", + }); + + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => + register( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, + }), + }), + ), + [register], + ); + + return useMemo( + () => ({ + connectPairingUrl, + updateBearer, + connectRelayEnvironment, + removeEnvironment: remove, + retryEnvironment: retryNow, + refreshRelayEnvironments, + }), + [ + connectPairingUrl, + connectRelayEnvironment, + refreshRelayEnvironments, + remove, + retryNow, + updateBearer, + ], + ); +} diff --git a/apps/mobile/src/state/filesystem.ts b/apps/mobile/src/state/filesystem.ts new file mode 100644 index 00000000000..19d5b53c4e0 --- /dev/null +++ b/apps/mobile/src/state/filesystem.ts @@ -0,0 +1,5 @@ +import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/git.ts b/apps/mobile/src/state/git.ts new file mode 100644 index 00000000000..66bb3dc0bde --- /dev/null +++ b/apps/mobile/src/state/git.ts @@ -0,0 +1,5 @@ +import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/orchestration.ts b/apps/mobile/src/state/orchestration.ts new file mode 100644 index 00000000000..8c6e1738857 --- /dev/null +++ b/apps/mobile/src/state/orchestration.ts @@ -0,0 +1,5 @@ +import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/presentation.ts b/apps/mobile/src/state/presentation.ts new file mode 100644 index 00000000000..83d1fdce462 --- /dev/null +++ b/apps/mobile/src/state/presentation.ts @@ -0,0 +1,31 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; +import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentSession } from "./session"; + +export const environmentPresentations = createEnvironmentPresentationAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + stateAtom: environmentCatalog.stateAtom, + configValueAtom: environmentSession.configValueAtom, +}); + +const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make(null).pipe( + Atom.withLabel("mobile-environment-presentation:empty"), +); + +export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const presentation = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM + : environmentPresentations.presentationAtom(environmentId), + ); + return { + isReady: catalog.isReady, + presentation, + }; +} diff --git a/apps/mobile/src/state/projects.ts b/apps/mobile/src/state/projects.ts new file mode 100644 index 00000000000..7a879988328 --- /dev/null +++ b/apps/mobile/src/state/projects.ts @@ -0,0 +1,12 @@ +import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; +import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts new file mode 100644 index 00000000000..68c23202308 --- /dev/null +++ b/apps/mobile/src/state/queries.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { buildCheckpointDiffTargets, normalizeComposerPathSearchQuery } from "./queryTargets"; + +describe("appQueries", () => { + it("normalizes composer path search input", () => { + expect(normalizeComposerPathSearchQuery(" src/app ")).toBe("src/app"); + expect(normalizeComposerPathSearchQuery(null)).toBe(""); + }); + + it("routes the first turn range through the full-thread diff query", () => { + const environmentId = EnvironmentId.make("environment-a"); + const threadId = ThreadId.make("thread-a"); + + expect( + buildCheckpointDiffTargets({ + environmentId, + threadId, + fromTurnCount: 0, + toTurnCount: 4, + ignoreWhitespace: true, + }), + ).toEqual({ + fullThread: { + environmentId, + input: { + threadId, + toTurnCount: 4, + ignoreWhitespace: true, + }, + }, + turn: null, + }); + }); + + it("routes later ranges through the incremental turn diff query", () => { + const environmentId = EnvironmentId.make("environment-a"); + const threadId = ThreadId.make("thread-a"); + + expect( + buildCheckpointDiffTargets({ + environmentId, + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + }), + ).toEqual({ + fullThread: null, + turn: { + environmentId, + input: { + threadId, + fromTurnCount: 3, + toTurnCount: 4, + ignoreWhitespace: false, + }, + }, + }); + }); +}); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts new file mode 100644 index 00000000000..ea625995928 --- /dev/null +++ b/apps/mobile/src/state/queries.ts @@ -0,0 +1,134 @@ +import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useEffect, useMemo, useState } from "react"; + +import { orchestrationEnvironment } from "./orchestration"; +import { projectEnvironment } from "./projects"; +import { useEnvironmentQuery } from "./query"; +import { useEnvironmentThread } from "./threads"; +import { vcsEnvironment } from "./vcs"; +import { + buildCheckpointDiffTargets, + normalizeComposerPathSearchQuery, + type CheckpointDiffTarget, +} from "./queryTargets"; + +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; +const COMPOSER_PATH_SEARCH_LIMIT = 20; +const VCS_REF_LIST_LIMIT = 100; + +export interface ThreadDetailView { + readonly data: OrchestrationThread | null; + readonly error: string | null; + readonly isPending: boolean; + readonly isDeleted: boolean; +} + +export interface ComposerPathSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string | null; +} + +function useDebouncedValue
(value: A, delayMs: number): A { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebounced(value); + }, delayMs); + return () => { + clearTimeout(timer); + }; + }, [delayMs, value]); + + return debounced; +} + +export function useThreadDetail( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): ThreadDetailView { + const state = useEnvironmentThread(environmentId, threadId); + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; +} + +export function useBranches(input: { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query?: string | null; +}) { + const query = input.query?.trim() ?? ""; + return useEnvironmentQuery( + input.environmentId !== null && input.cwd !== null + ? vcsEnvironment.listRefs({ + environmentId: input.environmentId, + input: { + cwd: input.cwd, + ...(query.length > 0 ? { query } : {}), + limit: VCS_REF_LIST_LIMIT, + }, + }) + : null, + ); +} + +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: normalizeComposerPathSearchQuery(target.query), + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? projectEnvironment.searchEntries({ + environmentId: debouncedTarget.environmentId, + input: { + cwd: debouncedTarget.cwd, + query: debouncedTarget.query, + limit: COMPOSER_PATH_SEARCH_LIMIT, + }, + }) + : null, + ); + + return { + entries: result.data?.entries ?? [], + error: result.error, + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + refresh: result.refresh, + }; +} + +export function useCheckpointDiff(target: CheckpointDiffTarget) { + const targets = useMemo( + () => buildCheckpointDiffTargets(target), + [ + target.environmentId, + target.fromTurnCount, + target.ignoreWhitespace, + target.threadId, + target.toTurnCount, + ], + ); + const fullThread = useEnvironmentQuery( + targets.fullThread === null + ? null + : orchestrationEnvironment.fullThreadDiff(targets.fullThread), + ); + const turn = useEnvironmentQuery( + targets.turn === null ? null : orchestrationEnvironment.turnDiff(targets.turn), + ); + return targets.fullThread === null ? turn : fullThread; +} diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts new file mode 100644 index 00000000000..c29d01d397b --- /dev/null +++ b/apps/mobile/src/state/query.ts @@ -0,0 +1,36 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( + Atom.withLabel("mobile-environment-query:empty"), +); + +export interface EnvironmentQueryView { + readonly data: A | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; +} + +function formatError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The environment request failed."; +} + +export function useEnvironmentQuery( + atom: Atom.Atom> | null, +): EnvironmentQueryView { + const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; + const result = useAtomValue(selectedAtom); + const refresh = useAtomRefresh(selectedAtom); + return { + data: Option.getOrNull(AsyncResult.value(result)), + error: result._tag === "Failure" ? formatError(result.cause) : null, + isPending: atom !== null && result.waiting, + refresh, + }; +} diff --git a/apps/mobile/src/state/queryTargets.ts b/apps/mobile/src/state/queryTargets.ts new file mode 100644 index 00000000000..a52da3fc134 --- /dev/null +++ b/apps/mobile/src/state/queryTargets.ts @@ -0,0 +1,51 @@ +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; + +export interface CheckpointDiffTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly fromTurnCount: number | null; + readonly toTurnCount: number | null; + readonly ignoreWhitespace: boolean; +} + +export function normalizeComposerPathSearchQuery(query: string | null): string { + return query?.trim() ?? ""; +} + +export function buildCheckpointDiffTargets(target: CheckpointDiffTarget) { + if ( + target.environmentId === null || + target.threadId === null || + target.fromTurnCount === null || + target.toTurnCount === null + ) { + return { fullThread: null, turn: null } as const; + } + + if (target.fromTurnCount === 0) { + return { + fullThread: { + environmentId: target.environmentId, + input: { + threadId: target.threadId, + toTurnCount: target.toTurnCount, + ignoreWhitespace: target.ignoreWhitespace, + }, + }, + turn: null, + } as const; + } + + return { + fullThread: null, + turn: { + environmentId: target.environmentId, + input: { + threadId: target.threadId, + fromTurnCount: target.fromTurnCount, + toTurnCount: target.toTurnCount, + ignoreWhitespace: target.ignoreWhitespace, + }, + }, + } as const; +} diff --git a/apps/mobile/src/state/relay.ts b/apps/mobile/src/state/relay.ts new file mode 100644 index 00000000000..f078572736b --- /dev/null +++ b/apps/mobile/src/state/relay.ts @@ -0,0 +1,6 @@ +import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const relayEnvironmentDiscovery = + createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/remote-http.ts b/apps/mobile/src/state/remote-http.ts new file mode 100644 index 00000000000..e0beae2c98a --- /dev/null +++ b/apps/mobile/src/state/remote-http.ts @@ -0,0 +1,67 @@ +import { useAtomValue } from "@effect/atom-react"; +import { ManagedRelayDpopSigner } from "@t3tools/client-runtime/relay"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +interface DpopRequest { + readonly url: string; + readonly accessToken: string; +} + +const remoteHttpHeadersAtom = Atom.family((key: string | null) => { + return connectionAtomRuntime.atom( + key === null + ? Effect.succeed> | null>(null) + : Effect.gen(function* () { + const request = JSON.parse(key) as DpopRequest; + const signer = yield* ManagedRelayDpopSigner; + const proof = yield* signer.createProof({ + method: "GET", + url: request.url, + accessToken: request.accessToken, + }); + return { + Authorization: `DPoP ${request.accessToken}`, + DPoP: proof, + } satisfies Readonly>; + }), + { initialValue: null }, + ); +}); + +export function useRemoteHttpHeaders(input: { + readonly url: string | null; + readonly bearerToken: string | null; + readonly dpopAccessToken?: string; +}): { + readonly headers: Readonly> | null; + readonly isReady: boolean; +} { + const dpopKey = + input.url !== null && input.dpopAccessToken + ? JSON.stringify({ + url: input.url, + accessToken: input.dpopAccessToken, + } satisfies DpopRequest) + : null; + const result = useAtomValue(remoteHttpHeadersAtom(dpopKey)); + + if (input.bearerToken) { + return { + headers: { Authorization: `Bearer ${input.bearerToken}` }, + isReady: true, + }; + } + if (dpopKey === null) { + return { headers: null, isReady: true }; + } + + const headers = Option.getOrNull(AsyncResult.value(result)); + return { + headers, + isReady: headers !== null, + }; +} diff --git a/apps/mobile/src/state/remote-runtime-types.ts b/apps/mobile/src/state/remote-runtime-types.ts index 054203715bd..89abd3c222e 100644 --- a/apps/mobile/src/state/remote-runtime-types.ts +++ b/apps/mobile/src/state/remote-runtime-types.ts @@ -1,27 +1,24 @@ -import type { - EnvironmentConnection, - EnvironmentConnectionState, - WsRpcClient, -} from "@t3tools/client-runtime"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { EnvironmentId, ThreadId, type ServerConfig } from "@t3tools/contracts"; -export type { EnvironmentRuntimeState } from "@t3tools/client-runtime"; +export interface EnvironmentRuntimeState { + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; + readonly serverConfig: ServerConfig | null; +} export interface ConnectedEnvironmentSummary { readonly environmentId: EnvironmentId; readonly environmentLabel: string; readonly displayUrl: string; readonly isRelayManaged: boolean; - readonly connectionState: EnvironmentConnectionState; + readonly connectionState: EnvironmentConnectionPhase; readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; } export interface SelectedThreadRef { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; } - -export interface EnvironmentSession { - readonly client: WsRpcClient; - readonly connection: EnvironmentConnection; -} diff --git a/apps/mobile/src/state/review.ts b/apps/mobile/src/state/review.ts new file mode 100644 index 00000000000..e4289d1f1d5 --- /dev/null +++ b/apps/mobile/src/state/review.ts @@ -0,0 +1,5 @@ +import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts new file mode 100644 index 00000000000..920c36bac8d --- /dev/null +++ b/apps/mobile/src/state/server.ts @@ -0,0 +1,12 @@ +import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSession } from "./session"; + +export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime); +export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + configValueAtom: environmentSession.configValueAtom, +}); diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts new file mode 100644 index 00000000000..5b23f48f6cc --- /dev/null +++ b/apps/mobile/src/state/session.ts @@ -0,0 +1,26 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { useEnvironmentQuery } from "./query"; + +export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); + +const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( + Atom.withLabel("mobile-prepared-connection:empty"), +); + +export function useEnvironmentConfig(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentSession.configAtom(environmentId)); +} + +export function usePreparedConnection(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_PREPARED_CONNECTION_ATOM + : environmentSession.preparedConnectionValueAtom(environmentId), + ); +} diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts new file mode 100644 index 00000000000..e879dd25e29 --- /dev/null +++ b/apps/mobile/src/state/shell.ts @@ -0,0 +1,17 @@ +import { + createEnvironmentShellAtoms, + createEnvironmentShellSummaryAtom, + createEnvironmentSnapshotAtom, + createShellEnvironmentAtoms, +} from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); +export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); +export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); +export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, +}); diff --git a/apps/mobile/src/state/sourceControl.ts b/apps/mobile/src/state/sourceControl.ts new file mode 100644 index 00000000000..aa6255f85ff --- /dev/null +++ b/apps/mobile/src/state/sourceControl.ts @@ -0,0 +1,5 @@ +import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/terminal.ts b/apps/mobile/src/state/terminal.ts new file mode 100644 index 00000000000..920267c33d5 --- /dev/null +++ b/apps/mobile/src/state/terminal.ts @@ -0,0 +1,5 @@ +import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts new file mode 100644 index 00000000000..ce50e2addfa --- /dev/null +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; + +import { + decodeQueuedThreadMessage, + groupQueuedThreadMessages, + threadOutboxRetryDelayMs, + type QueuedThreadMessage, +} from "./thread-outbox"; + +function queuedMessage(input: { + readonly environmentId?: string; + readonly threadId?: string; + readonly messageId: string; + readonly createdAt: string; +}): QueuedThreadMessage { + return { + environmentId: EnvironmentId.make(input.environmentId ?? "environment-1"), + threadId: ThreadId.make(input.threadId ?? "thread-1"), + messageId: MessageId.make(input.messageId), + commandId: CommandId.make(`command-${input.messageId}`), + text: input.messageId, + attachments: [], + createdAt: input.createdAt, + }; +} + +describe("thread outbox", () => { + it("groups messages by scoped thread and preserves creation order", () => { + const later = queuedMessage({ + messageId: "message-2", + createdAt: "2026-06-08T10:00:02.000Z", + }); + const earlier = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + expect(groupQueuedThreadMessages([later, earlier])).toEqual({ + "environment-1:thread-1": [earlier, later], + }); + }); + + it("decodes the persisted schema and rejects incomplete messages", () => { + const message = queuedMessage({ + messageId: "message-1", + createdAt: "2026-06-08T10:00:01.000Z", + }); + + expect( + decodeQueuedThreadMessage({ + schemaVersion: 1, + ...message, + }), + ).toEqual(message); + expect(() => + decodeQueuedThreadMessage({ + schemaVersion: 1, + environmentId: "environment-1", + }), + ).toThrow(); + }); + + it("backs off queued delivery retries and caps them at sixteen seconds", () => { + expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([ + 1_000, 2_000, 4_000, 8_000, 16_000, 16_000, + ]); + }); +}); diff --git a/apps/mobile/src/state/thread-outbox.ts b/apps/mobile/src/state/thread-outbox.ts new file mode 100644 index 00000000000..6de14460732 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox.ts @@ -0,0 +1,210 @@ +import { useAtomValue } from "@effect/atom-react"; +import { CommandId, EnvironmentId, IsoDateTime, MessageId, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { Atom } from "effect/unstable/reactivity"; + +import type { DraftComposerImageAttachment } from "../lib/composerImages"; +import { scopedThreadKey } from "../lib/scopedEntities"; +import { appAtomRegistry } from "./atom-registry"; + +const THREAD_OUTBOX_SCHEMA_VERSION = 1; +const THREAD_OUTBOX_DIRECTORY = "thread-outbox"; +const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; + +const DraftComposerImageAttachmentSchema = Schema.Struct({ + id: Schema.String, + previewUri: Schema.String, + type: Schema.Literal("image"), + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + dataUrl: Schema.String, +}); + +export const QueuedThreadMessageSchema = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_OUTBOX_SCHEMA_VERSION), + environmentId: EnvironmentId, + threadId: ThreadId, + messageId: MessageId, + commandId: CommandId, + text: Schema.String, + attachments: Schema.Array(DraftComposerImageAttachmentSchema), + createdAt: IsoDateTime, +}); + +const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema); +const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema); + +type StoredQueuedThreadMessage = typeof QueuedThreadMessageSchema.Type; + +export interface QueuedThreadMessage { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly commandId: CommandId; + readonly text: string; + readonly attachments: ReadonlyArray; + readonly createdAt: string; +} + +export const queuedMessagesByThreadKeyAtom = Atom.make< + Record> +>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:queued-messages")); + +let loadPromise: Promise | null = null; + +function storedMessage(message: QueuedThreadMessage): StoredQueuedThreadMessage { + return { + schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, + ...message, + }; +} + +function messageFileName(messageId: MessageId): string { + return `${encodeURIComponent(messageId)}.json`; +} + +async function getOutboxDirectory() { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, THREAD_OUTBOX_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + return directory; +} + +async function getMessageFile(messageId: MessageId) { + const { File } = await import("expo-file-system"); + return new File(await getOutboxDirectory(), messageFileName(messageId)); +} + +export function groupQueuedThreadMessages( + messages: ReadonlyArray, +): Record> { + const deduplicated = new Map(); + for (const message of messages) { + deduplicated.set(message.messageId, message); + } + + const grouped: Record> = {}; + for (const message of deduplicated.values()) { + const threadKey = scopedThreadKey(message.environmentId, message.threadId); + (grouped[threadKey] ??= []).push(message); + } + for (const queue of Object.values(grouped)) { + queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + return grouped; +} + +export function threadOutboxRetryDelayMs(attempt: number): number { + return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS); +} + +export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage { + const { schemaVersion: _, ...message } = decodeStoredQueuedThreadMessage(value); + return message; +} + +function flattenQueues( + queues: Record>, +): ReadonlyArray { + return Object.values(queues).flat(); +} + +async function loadPersistedMessages(): Promise> { + const { File } = await import("expo-file-system"); + const directory = await getOutboxDirectory(); + const messages: Array = []; + + for (const entry of directory.list()) { + if (!(entry instanceof File) || !entry.name.endsWith(".json")) { + continue; + } + try { + messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); + } catch (error) { + console.warn("[thread-outbox] ignored invalid persisted message", entry.name, error); + } + } + return messages; +} + +export function ensureThreadOutboxLoaded(): void { + if (loadPromise !== null) { + return; + } + loadPromise = loadPersistedMessages() + .then((persistedMessages) => { + const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom)); + appAtomRegistry.set( + queuedMessagesByThreadKeyAtom, + groupQueuedThreadMessages([...persistedMessages, ...current]), + ); + }) + .catch((error) => { + console.warn("[thread-outbox] failed to load persisted messages", error); + }); +} + +export async function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise { + const encoded = encodeStoredQueuedThreadMessage(storedMessage(message)); + const file = await getMessageFile(message.messageId); + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + + const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom)); + appAtomRegistry.set( + queuedMessagesByThreadKeyAtom, + groupQueuedThreadMessages([...current, message]), + ); +} + +export async function removeThreadOutboxMessage(message: QueuedThreadMessage): Promise { + const file = await getMessageFile(message.messageId); + if (file.exists) { + file.delete(); + } + + const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom)); + appAtomRegistry.set( + queuedMessagesByThreadKeyAtom, + groupQueuedThreadMessages( + current.filter((candidate) => candidate.messageId !== message.messageId), + ), + ); +} + +export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise { + const current = flattenQueues(appAtomRegistry.get(queuedMessagesByThreadKeyAtom)); + const persisted = await loadPersistedMessages().catch((error) => { + console.warn("[thread-outbox] failed to load messages while clearing environment", error); + return []; + }); + const allMessages = flattenQueues(groupQueuedThreadMessages([...persisted, ...current])); + const removed = allMessages.filter((message) => message.environmentId === environmentId); + + await Promise.all( + removed.map(async (message) => { + try { + const file = await getMessageFile(message.messageId); + if (file.exists) { + file.delete(); + } + } catch (error) { + console.warn("[thread-outbox] failed to clear persisted message", error); + } + }), + ); + + appAtomRegistry.set( + queuedMessagesByThreadKeyAtom, + groupQueuedThreadMessages( + allMessages.filter((message) => message.environmentId !== environmentId), + ), + ); +} + +export function useThreadOutboxMessages() { + return useAtomValue(queuedMessagesByThreadKeyAtom); +} diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts new file mode 100644 index 00000000000..7f247123051 --- /dev/null +++ b/apps/mobile/src/state/threads.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createEnvironmentThreadDetailAtoms, + createEnvironmentThreadShellAtoms, + createEnvironmentThreadStateAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, + createThreadEnvironmentAtoms, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( + environmentThreads.stateAtom, +); +export const environmentThreadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); + +const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( + Atom.withLabel("mobile-environment-thread:empty"), +); + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + const result = useAtomValue( + environmentId !== null && threadId !== null + ? environmentThreads.stateAtom(environmentId, threadId) + : EMPTY_THREAD_STATE_ATOM, + ); + return Option.getOrElse( + AsyncResult.value(result), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ) as EnvironmentThreadState; +} diff --git a/apps/mobile/src/state/use-checkpoint-diff.ts b/apps/mobile/src/state/use-checkpoint-diff.ts deleted file mode 100644 index 3111008f00a..00000000000 --- a/apps/mobile/src/state/use-checkpoint-diff.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createCheckpointDiffManager, type CheckpointDiffTarget } from "@t3tools/client-runtime"; - -import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; - -export const checkpointDiffManager = createCheckpointDiffManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.orchestration ?? null, -}); - -export function loadCheckpointDiff( - target: CheckpointDiffTarget, - options?: { readonly force?: boolean }, -) { - return checkpointDiffManager.load(target, undefined, options); -} diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts new file mode 100644 index 00000000000..48e4e8703f0 --- /dev/null +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { type ComposerDraft, removeComposerDraftsForEnvironment } from "./use-composer-drafts"; + +const DRAFT: ComposerDraft = { + text: "hello", + attachments: [], +}; + +describe("mobile composer drafts", () => { + it("removes only drafts owned by the selected environment", () => { + const environmentId = EnvironmentId.make("environment-cloud"); + const retainedEnvironmentId = EnvironmentId.make("environment-local"); + + expect( + removeComposerDraftsForEnvironment( + { + [`${environmentId}:thread-cloud`]: DRAFT, + [`${retainedEnvironmentId}:thread-local`]: DRAFT, + }, + environmentId, + ), + ).toEqual({ + [`${retainedEnvironmentId}:thread-local`]: DRAFT, + }); + }); +}); diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 6ac9786ad0e..ab1fea9840d 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useEffect } from "react"; import { Atom } from "effect/unstable/reactivity"; @@ -30,7 +31,7 @@ export const composerDraftsAtom = Atom.make>({}).p Atom.withLabel("mobile:composer-drafts"), ); -let loadStarted = false; +let loadPromise: Promise | null = null; let persistTimer: ReturnType | null = null; function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft { @@ -79,20 +80,24 @@ async function loadPersistedComposerDrafts(): Promise): Promise { + const file = await getComposerDraftsFile(); + const nonEmptyDrafts = Object.fromEntries( + Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), + ); + const document: PersistedComposerDrafts = { + schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, + drafts: nonEmptyDrafts, + }; + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(document)); +} + async function savePersistedComposerDrafts(drafts: Record): Promise { try { - const file = await getComposerDraftsFile(); - const nonEmptyDrafts = Object.fromEntries( - Object.entries(drafts).filter(([, draft]) => !isEmptyDraft(draft)), - ); - const document: PersistedComposerDrafts = { - schemaVersion: COMPOSER_DRAFTS_SCHEMA_VERSION, - drafts: nonEmptyDrafts, - }; - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(document)); + await writePersistedComposerDrafts(drafts); } catch { // Draft persistence is best-effort; in-memory drafts still keep working. } @@ -109,20 +114,23 @@ function schedulePersistComposerDrafts(drafts: Record): v } export function ensureComposerDraftsLoaded(): void { - if (loadStarted) { + if (loadPromise !== null) { return; } - loadStarted = true; - void loadPersistedComposerDrafts().then((persistedDrafts) => { - if (Object.keys(persistedDrafts).length === 0) { - return; - } - const current = appAtomRegistry.get(composerDraftsAtom); - appAtomRegistry.set(composerDraftsAtom, { - ...persistedDrafts, - ...current, + loadPromise = loadPersistedComposerDrafts() + .then((persistedDrafts) => { + if (Object.keys(persistedDrafts).length === 0) { + return; + } + const current = appAtomRegistry.get(composerDraftsAtom); + appAtomRegistry.set(composerDraftsAtom, { + ...persistedDrafts, + ...current, + }); + }) + .catch(() => { + // Draft loading is best-effort; in-memory drafts still keep working. }); - }); } function updateComposerDrafts( @@ -234,6 +242,35 @@ export function clearComposerDraft(draftKey: string): void { }); } +export function removeComposerDraftsForEnvironment( + drafts: Record, + environmentId: EnvironmentId, +): Record { + const environmentPrefix = `${environmentId}:`; + return Object.fromEntries( + Object.entries(drafts).filter(([draftKey]) => !draftKey.startsWith(environmentPrefix)), + ); +} + +export async function clearComposerDraftsEnvironment(environmentId: EnvironmentId): Promise { + ensureComposerDraftsLoaded(); + if (loadPromise !== null) { + await loadPromise; + } + + const next = removeComposerDraftsForEnvironment( + appAtomRegistry.get(composerDraftsAtom), + environmentId, + ); + + if (persistTimer !== null) { + clearTimeout(persistTimer); + persistTimer = null; + } + appAtomRegistry.set(composerDraftsAtom, next); + await writePersistedComposerDrafts(next); +} + export function useComposerDraft(draftKey: string | null): ComposerDraft { const drafts = useAtomValue(composerDraftsAtom); useEffect(() => { diff --git a/apps/mobile/src/state/use-composer-path-search.ts b/apps/mobile/src/state/use-composer-path-search.ts index a42143a427b..485b472dcb0 100644 --- a/apps/mobile/src/state/use-composer-path-search.ts +++ b/apps/mobile/src/state/use-composer-path-search.ts @@ -1,46 +1,7 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type ComposerPathSearchState, - type ComposerPathSearchTarget, - EMPTY_COMPOSER_PATH_SEARCH_ATOM, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - composerPathSearchStateAtom, - createComposerPathSearchManager, - getComposerPathSearchTargetKey, - normalizeComposerPathSearchQuery, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +import { type ComposerPathSearchTarget } from "@t3tools/client-runtime/state/threads"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; -const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; - -const composerPathSearchManager = createComposerPathSearchManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.projects ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, -}); - -export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getComposerPathSearchTargetKey(stableTarget); - - useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue( - targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, - ); - return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useComposerPathSearchQuery(target); } diff --git a/apps/mobile/src/state/use-environment-runtime.ts b/apps/mobile/src/state/use-environment-runtime.ts deleted file mode 100644 index f4a65a0d283..00000000000 --- a/apps/mobile/src/state/use-environment-runtime.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_ENVIRONMENT_RUNTIME_ATOM, - EMPTY_ENVIRONMENT_RUNTIME_STATE, - createEnvironmentRuntimeManager, - environmentRuntimeStateAtom, - getEnvironmentRuntimeTargetKey, - type EnvironmentRuntimeState, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; - -export const environmentRuntimeManager = createEnvironmentRuntimeManager({ - getRegistry: () => appAtomRegistry, -}); - -export function useEnvironmentRuntime( - environmentId: EnvironmentId | null, -): EnvironmentRuntimeState { - const targetKey = getEnvironmentRuntimeTargetKey({ environmentId }); - const state = useAtomValue( - targetKey !== null ? environmentRuntimeStateAtom(targetKey) : EMPTY_ENVIRONMENT_RUNTIME_ATOM, - ); - return targetKey === null ? EMPTY_ENVIRONMENT_RUNTIME_STATE : state; -} - -export function useEnvironmentRuntimeStates( - environmentIds: ReadonlyArray, -): Readonly> { - const stableEnvironmentIds = useMemo( - () => Arr.sort(new Set(environmentIds), Order.String), - [environmentIds], - ); - const snapshotCacheRef = useRef>>({}); - - const subscribe = useCallback( - (onStoreChange: () => void) => { - const unsubs = stableEnvironmentIds.map((environmentId) => - appAtomRegistry.subscribe(environmentRuntimeStateAtom(environmentId), onStoreChange), - ); - return () => { - for (const unsub of unsubs) { - unsub(); - } - }; - }, - [stableEnvironmentIds], - ); - - const getSnapshot = useCallback(() => { - const previous = snapshotCacheRef.current; - let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; - const next: Record = {}; - - for (const environmentId of stableEnvironmentIds) { - const snapshot = environmentRuntimeManager.getSnapshot({ environmentId }); - next[environmentId] = snapshot; - if (!hasChanged && previous[environmentId] !== snapshot) { - hasChanged = true; - } - } - - if (!hasChanged) { - return previous; - } - - snapshotCacheRef.current = next; - return next; - }, [stableEnvironmentIds]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/apps/mobile/src/state/use-filesystem-browse.ts b/apps/mobile/src/state/use-filesystem-browse.ts deleted file mode 100644 index e5ab77a80af..00000000000 --- a/apps/mobile/src/state/use-filesystem-browse.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_FILESYSTEM_BROWSE_ATOM, - EMPTY_FILESYSTEM_BROWSE_STATE, - type FilesystemBrowseClient, - type FilesystemBrowseState, - type FilesystemBrowseTarget, - createFilesystemBrowseManager, - filesystemBrowseStateAtom, - getFilesystemBrowseTargetKey, -} from "@t3tools/client-runtime"; -import type { - EnvironmentId, - FilesystemBrowseInput, - FilesystemBrowseResult, -} from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const filesystemBrowseManager = createFilesystemBrowseManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.filesystem ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -function filesystemBrowseTargetForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): FilesystemBrowseTarget { - return { key: environmentId, input }; -} - -export function refreshFilesystemBrowseForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, - client?: FilesystemBrowseClient | null, -): Promise { - return filesystemBrowseManager.refresh( - filesystemBrowseTargetForEnvironment(environmentId, input), - client ?? undefined, - ); -} - -export function invalidateFilesystemBrowseForEnvironment( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): void { - filesystemBrowseManager.invalidate(filesystemBrowseTargetForEnvironment(environmentId, input)); -} - -export function resetFilesystemBrowseState(): void { - filesystemBrowseManager.reset(); -} - -export function resetFilesystemBrowseStateForTests(): void { - resetFilesystemBrowseState(); -} - -export function useFilesystemBrowse( - environmentId: EnvironmentId | null, - input: FilesystemBrowseInput | null, -): FilesystemBrowseState { - const target = useMemo( - () => filesystemBrowseTargetForEnvironment(environmentId, input), - [environmentId, input], - ); - - useEffect(() => { - return filesystemBrowseManager.watch(target); - }, [target]); - - const targetKey = getFilesystemBrowseTargetKey(target); - const state = useAtomValue( - targetKey !== null ? filesystemBrowseStateAtom(targetKey) : EMPTY_FILESYSTEM_BROWSE_ATOM, - ); - return targetKey === null ? EMPTY_FILESYSTEM_BROWSE_STATE : state; -} diff --git a/apps/mobile/src/state/use-remote-catalog.ts b/apps/mobile/src/state/use-remote-catalog.ts deleted file mode 100644 index 8a5ddac2c0f..00000000000 --- a/apps/mobile/src/state/use-remote-catalog.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { useMemo } from "react"; -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; - -import { - EnvironmentConnectionState, - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - scopeProjectShell, - scopeThreadShell, -} from "@t3tools/client-runtime"; - -import { ConnectedEnvironmentSummary } from "./remote-runtime-types"; -import type { SavedRemoteConnection } from "../lib/connection"; -import { useCachedShellSnapshotMetadata, useShellSnapshotStates } from "./use-shell-snapshot"; -import { - useRemoteConnectionStatus, - useRemoteEnvironmentState, -} from "./use-remote-environment-registry"; - -const projectsSortOrder = Order.mapInput( - Order.Struct({ - title: Order.String, - environmentId: Order.String, - }), - (project: EnvironmentScopedProjectShell) => ({ - title: project.title, - environmentId: project.environmentId, - }), -); - -const threadsSortOrder = Order.mapInput( - Order.Struct({ - activityAt: Order.flip(Order.String), - environmentId: Order.String, - }), - (thread: EnvironmentScopedThreadShell) => ({ - activityAt: thread.updatedAt ?? thread.createdAt, - environmentId: thread.environmentId, - }), -); - -function deriveOverallConnectionState( - environments: ReadonlyArray, -): EnvironmentConnectionState { - if (environments.length === 0) { - return "idle"; - } - if (environments.some((environment) => environment.connectionState === "ready")) { - return "ready"; - } - if (environments.some((environment) => environment.connectionState === "reconnecting")) { - return "reconnecting"; - } - if (environments.some((environment) => environment.connectionState === "connecting")) { - return "connecting"; - } - return "disconnected"; -} - -function listRemoteCatalogEnvironmentIds( - savedConnectionsById: Readonly>, -): ReadonlyArray { - const environmentIds: SavedRemoteConnection["environmentId"][] = []; - for (const connection of Object.values(savedConnectionsById)) { - environmentIds.push(connection.environmentId); - } - return environmentIds; -} - -export interface RemoteCatalogState { - readonly isLoadingSavedConnections: boolean; - readonly hasSavedConnections: boolean; - readonly hasLoadedShellSnapshot: boolean; - readonly hasPendingShellSnapshot: boolean; - readonly hasReadyEnvironment: boolean; - readonly hasConnectingEnvironment: boolean; - readonly connectionState: EnvironmentConnectionState; - readonly connectionError: string | null; - readonly shellSnapshotError: string | null; - readonly isUsingCachedData: boolean; - readonly latestCachedSnapshotReceivedAt: string | null; -} - -export function useRemoteCatalog() { - const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); - const { environmentStateById, isLoadingSavedConnection, savedConnectionsById } = - useRemoteEnvironmentState(); - const catalogEnvironmentIds = useMemo( - () => listRemoteCatalogEnvironmentIds(savedConnectionsById), - [savedConnectionsById], - ); - const shellSnapshotStates = useShellSnapshotStates(catalogEnvironmentIds); - const cachedShellSnapshotMetadata = useCachedShellSnapshotMetadata(); - - const projects = useMemo(() => { - const scopedProjects: EnvironmentScopedProjectShell[] = []; - for (const connection of Object.values(savedConnectionsById)) { - const projects = shellSnapshotStates[connection.environmentId]?.data?.projects ?? []; - for (const project of projects) { - scopedProjects.push(scopeProjectShell(connection.environmentId, project)); - } - } - return Arr.sort(scopedProjects, projectsSortOrder); - }, [savedConnectionsById, shellSnapshotStates]); - - const threads = useMemo(() => { - const scopedThreads: EnvironmentScopedThreadShell[] = []; - for (const connection of Object.values(savedConnectionsById)) { - const threads = shellSnapshotStates[connection.environmentId]?.data?.threads ?? []; - for (const thread of threads) { - scopedThreads.push(scopeThreadShell(connection.environmentId, thread)); - } - } - return Arr.sort(scopedThreads, threadsSortOrder); - }, [savedConnectionsById, shellSnapshotStates]); - - const serverConfigByEnvironmentId = useMemo( - () => - Object.fromEntries( - Object.entries(environmentStateById).map(([environmentId, runtime]) => [ - environmentId, - runtime.serverConfig ?? null, - ]), - ), - [environmentStateById], - ); - - const overallConnectionState = useMemo( - () => deriveOverallConnectionState(connectedEnvironments), - [connectedEnvironments], - ); - - const hasRemoteActivity = useMemo( - () => - threads.some( - (thread) => thread.session?.status === "running" || thread.session?.status === "starting", - ), - [threads], - ); - - const state = useMemo(() => { - const shellSnapshots = Object.values(shellSnapshotStates); - const cachedSnapshotReceivedAts: string[] = []; - for (const environmentId of catalogEnvironmentIds) { - const metadata = cachedShellSnapshotMetadata[environmentId]; - if (metadata) { - cachedSnapshotReceivedAts.push(metadata.snapshotReceivedAt); - } - } - let shellSnapshotError: string | null = null; - for (const snapshot of shellSnapshots) { - if (snapshot.error !== null) { - shellSnapshotError = snapshot.error; - break; - } - } - return { - isLoadingSavedConnections: isLoadingSavedConnection, - hasSavedConnections: catalogEnvironmentIds.length > 0, - hasLoadedShellSnapshot: shellSnapshots.some((snapshot) => snapshot.data !== null), - hasPendingShellSnapshot: shellSnapshots.some((snapshot) => snapshot.isPending), - hasReadyEnvironment: connectedEnvironments.some( - (environment) => environment.connectionState === "ready", - ), - hasConnectingEnvironment: connectedEnvironments.some( - (environment) => - environment.connectionState === "connecting" || - environment.connectionState === "reconnecting", - ), - connectionState: connectionState ?? overallConnectionState, - connectionError, - shellSnapshotError, - isUsingCachedData: cachedSnapshotReceivedAts.length > 0, - latestCachedSnapshotReceivedAt: - Arr.sort(cachedSnapshotReceivedAts, Order.flip(Order.String))[0] ?? null, - }; - }, [ - cachedShellSnapshotMetadata, - catalogEnvironmentIds, - connectedEnvironments, - connectionError, - connectionState, - isLoadingSavedConnection, - overallConnectionState, - shellSnapshotStates, - ]); - - return { - projects, - threads, - serverConfigByEnvironmentId, - connectionState: state.connectionState, - connectionError: state.connectionError, - state, - hasRemoteActivity, - }; -} diff --git a/apps/mobile/src/state/use-remote-environment-registry.test.ts b/apps/mobile/src/state/use-remote-environment-registry.test.ts deleted file mode 100644 index fc465bbfb88..00000000000 --- a/apps/mobile/src/state/use-remote-environment-registry.test.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; -import { - createManagedRelaySession, - ManagedRelayDpopSigner, - setManagedRelaySession, -} from "@t3tools/client-runtime"; -import * as Effect from "effect/Effect"; -import { beforeEach, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => { - const environmentConnection = { - ensureBootstrapped: vi.fn(() => Promise.resolve()), - dispose: vi.fn(() => Promise.resolve()), - }; - const sessionConnection = { - dispose: vi.fn(() => Promise.resolve()), - reconnect: vi.fn(() => Promise.resolve()), - }; - const sessionClient = { - isHeartbeatFresh: vi.fn(() => false), - }; - return { - environmentConnection, - sessionConnection, - sessionClient, - createEnvironmentConnection: vi.fn(() => environmentConnection), - createKnownEnvironment: vi.fn((input: unknown) => input), - createWsRpcClient: vi.fn(() => ({ rpc: true })), - wsTransportConstructor: vi.fn(), - resolveRemoteWebSocketConnectionUrl: vi.fn(() => ({ _tag: "remote-ws-url-effect" })), - resolveRemoteDpopWebSocketConnectionUrl: vi.fn(), - remoteEndpointUrl: vi.fn((baseUrl: string, path: string) => new URL(path, baseUrl).toString()), - createDpopProof: vi.fn(), - refreshCloudEnvironmentConnection: vi.fn(), - bootstrapRemoteConnection: vi.fn(), - clearCachedShellSnapshot: vi.fn(() => Promise.resolve()), - clearSavedConnection: vi.fn(() => Promise.resolve()), - saveConnection: vi.fn((_connection?: unknown) => Promise.resolve()), - saveCachedShellSnapshot: vi.fn(() => Promise.resolve()), - mobileRunPromise: vi.fn((_effect?: unknown) => - Promise.resolve("wss://desktop.example/ws?wsTicket=token"), - ), - removeEnvironmentSession: vi.fn(() => null), - getEnvironmentSession: vi.fn(() => null), - setEnvironmentSession: vi.fn(), - notifyEnvironmentConnectionListeners: vi.fn(), - unregisterAgentAwarenessConnection: vi.fn(), - registerAgentAwarenessConnection: vi.fn(), - shellSnapshotInvalidate: vi.fn(), - shellSnapshotMarkPending: vi.fn(), - environmentRuntimeInvalidate: vi.fn(), - environmentRuntimePatch: vi.fn(), - clearCachedShellSnapshotMetadata: vi.fn(), - invalidateSourceControlDiscoveryForEnvironment: vi.fn(), - terminalSessionInvalidateEnvironment: vi.fn(), - subscribeTerminalMetadata: vi.fn(() => vi.fn()), - terminalDebugLog: vi.fn(), - WsTransport: function WsTransport(...args: ReadonlyArray) { - mocks.wsTransportConstructor(...args); - }, - }; -}); - -vi.mock("react-native", () => ({ - Alert: { - alert: vi.fn(), - }, - AppState: { - currentState: "active", - addEventListener: vi.fn(() => ({ remove: vi.fn() })), - }, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - WsTransport: mocks.WsTransport, - createEnvironmentConnection: mocks.createEnvironmentConnection, - createKnownEnvironment: mocks.createKnownEnvironment, - createWsRpcClient: mocks.createWsRpcClient, - remoteEndpointUrl: mocks.remoteEndpointUrl, - resolveRemoteDpopWebSocketConnectionUrl: mocks.resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl: mocks.resolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../lib/connection", async (importOriginal) => ({ - ...(await importOriginal()), - bootstrapRemoteConnection: mocks.bootstrapRemoteConnection, -})); - -vi.mock("../features/cloud/linkEnvironment", () => ({ - refreshCloudEnvironmentConnection: mocks.refreshCloudEnvironmentConnection, -})); - -vi.mock("../lib/storage", () => ({ - clearCachedShellSnapshot: mocks.clearCachedShellSnapshot, - clearSavedConnection: mocks.clearSavedConnection, - loadCachedShellSnapshot: vi.fn(() => Promise.resolve(null)), - loadSavedConnections: vi.fn(() => Promise.resolve([])), - saveCachedShellSnapshot: mocks.saveCachedShellSnapshot, - saveConnection: mocks.saveConnection, -})); - -vi.mock("../lib/runtime", () => ({ - mobileRuntime: { - runPromise: mocks.mobileRunPromise, - }, -})); - -vi.mock("./environment-session-registry", () => ({ - drainEnvironmentSessions: vi.fn(() => []), - getEnvironmentSession: mocks.getEnvironmentSession, - notifyEnvironmentConnectionListeners: mocks.notifyEnvironmentConnectionListeners, - removeEnvironmentSession: mocks.removeEnvironmentSession, - setEnvironmentSession: mocks.setEnvironmentSession, -})); - -vi.mock("../features/agent-awareness/remoteRegistration", () => ({ - registerAgentAwarenessConnection: mocks.registerAgentAwarenessConnection, - unregisterAgentAwarenessConnection: mocks.unregisterAgentAwarenessConnection, - unregisterAllAgentAwarenessConnections: vi.fn(), -})); - -vi.mock("../features/terminal/terminalDebugLog", () => ({ - terminalDebugLog: mocks.terminalDebugLog, -})); - -vi.mock("./use-environment-runtime", () => ({ - environmentRuntimeManager: { - invalidate: mocks.environmentRuntimeInvalidate, - patch: mocks.environmentRuntimePatch, - }, - useEnvironmentRuntimeStates: vi.fn(() => ({})), -})); - -vi.mock("./use-shell-snapshot", () => ({ - clearCachedShellSnapshotMetadata: mocks.clearCachedShellSnapshotMetadata, - hydrateCachedShellSnapshot: vi.fn(), - markShellSnapshotLive: vi.fn(), - shellSnapshotManager: { - applyEvent: vi.fn(), - invalidate: mocks.shellSnapshotInvalidate, - markPending: mocks.shellSnapshotMarkPending, - syncSnapshot: vi.fn(), - }, -})); - -vi.mock("./use-source-control-discovery", () => ({ - invalidateSourceControlDiscoveryForEnvironment: - mocks.invalidateSourceControlDiscoveryForEnvironment, - resetSourceControlDiscoveryState: vi.fn(), -})); - -vi.mock("./use-terminal-session", () => ({ - subscribeTerminalMetadata: mocks.subscribeTerminalMetadata, - terminalSessionManager: { - invalidate: vi.fn(), - invalidateEnvironment: mocks.terminalSessionInvalidateEnvironment, - }, -})); - -import { - connectSavedEnvironment, - disconnectEnvironment, - reconnectEnvironmentConnectionsAfterAppResume, -} from "./use-remote-environment-registry"; -import { appAtomRegistry } from "./atom-registry"; - -const environmentId = EnvironmentId.make("env-mobile-test"); - -const connection = { - environmentId, - environmentLabel: "Mobile Test Desktop", - pairingUrl: "https://desktop.example/", - displayUrl: "https://desktop.example/", - httpBaseUrl: "https://desktop.example/", - wsBaseUrl: "wss://desktop.example/", - bearerToken: "remote-access-token", -} as const; - -describe("mobile remote environment registry effects", () => { - beforeEach(() => { - vi.clearAllMocks(); - mocks.createEnvironmentConnection.mockReturnValue(mocks.environmentConnection); - mocks.environmentConnection.ensureBootstrapped.mockResolvedValue(undefined); - mocks.environmentConnection.dispose.mockResolvedValue(undefined); - mocks.sessionConnection.dispose.mockResolvedValue(undefined); - mocks.sessionConnection.reconnect.mockResolvedValue(undefined); - mocks.sessionClient.isHeartbeatFresh.mockReturnValue(false); - mocks.removeEnvironmentSession.mockReturnValue(null); - mocks.getEnvironmentSession.mockReturnValue(null); - mocks.mobileRunPromise.mockResolvedValue("wss://desktop.example/ws?wsTicket=token"); - mocks.createDpopProof.mockReturnValue(Effect.succeed("dpop-proof")); - mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.die("unexpected refresh")); - mocks.resolveRemoteDpopWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://desktop.example/ws?wsTicket=dpop-token"), - ); - setManagedRelaySession(appAtomRegistry, null); - }); - - it.effect("connects a saved managed endpoint environment through Effect-wrapped APIs", () => - Effect.gen(function* () { - yield* connectSavedEnvironment(connection); - - expect(mocks.saveConnection).toHaveBeenCalledWith(connection); - expect(mocks.wsTransportConstructor).toHaveBeenCalledTimes(1); - expect(mocks.createEnvironmentConnection).toHaveBeenCalledTimes(1); - expect(mocks.setEnvironmentSession).toHaveBeenCalledWith( - connection.environmentId, - expect.objectContaining({ - connection: mocks.environmentConnection, - }), - ); - expect(mocks.subscribeTerminalMetadata).toHaveBeenCalledWith( - expect.objectContaining({ environmentId: connection.environmentId }), - ); - expect(mocks.registerAgentAwarenessConnection).toHaveBeenCalledWith(connection); - expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); - }), - ); - - it.effect("uses DPoP-bound admission for a managed DPoP connection", () => - Effect.gen(function* () { - const dpopConnection = { - ...connection, - bearerToken: null, - authenticationMethod: "dpop", - dpopAccessToken: "environment-dpop-token", - } as const; - mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => - Effect.runPromise( - (effect as Effect.Effect).pipe( - Effect.provideService( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("mobile-key-thumbprint"), - createProof: mocks.createDpopProof, - }), - ), - ), - ), - ); - - yield* connectSavedEnvironment(dpopConnection); - const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as - | (() => Promise) - | undefined; - expect(openSocket).toBeDefined(); - yield* Effect.promise(() => openSocket!()); - - expect(mocks.createDpopProof).toHaveBeenCalledWith({ - method: "POST", - url: "https://desktop.example/api/auth/websocket-ticket", - accessToken: "environment-dpop-token", - }); - expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: dpopConnection.wsBaseUrl, - httpBaseUrl: dpopConnection.httpBaseUrl, - accessToken: "environment-dpop-token", - dpopProof: "dpop-proof", - }); - expect(mocks.resolveRemoteWebSocketConnectionUrl).not.toHaveBeenCalled(); - }), - ); - - it.effect("refreshes a persisted managed connection before reconnecting", () => - Effect.gen(function* () { - const savedDpopConnection = { - ...connection, - bearerToken: null, - authenticationMethod: "dpop", - relayManaged: true, - } as const; - const refreshedConnection = { - ...savedDpopConnection, - displayUrl: "https://rotated-desktop.example/", - httpBaseUrl: "https://rotated-desktop.example/", - wsBaseUrl: "wss://rotated-desktop.example/", - dpopAccessToken: "fresh-environment-dpop-token", - } as const; - setManagedRelaySession( - appAtomRegistry, - createManagedRelaySession({ - accountId: "account-1", - readClerkToken: () => Promise.resolve("fresh-clerk-token"), - }), - ); - mocks.refreshCloudEnvironmentConnection.mockReturnValue(Effect.succeed(refreshedConnection)); - mocks.mobileRunPromise.mockImplementationOnce((effect?: unknown) => - Effect.runPromise( - (effect as Effect.Effect).pipe( - Effect.provideService( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("mobile-key-thumbprint"), - createProof: mocks.createDpopProof, - }), - ), - ), - ), - ); - - yield* connectSavedEnvironment(savedDpopConnection, { persist: false }); - const openSocket = mocks.wsTransportConstructor.mock.calls[0]?.[0] as - | (() => Promise) - | undefined; - expect(openSocket).toBeDefined(); - yield* Effect.promise(() => openSocket!()); - - expect(mocks.refreshCloudEnvironmentConnection).toHaveBeenCalledWith({ - clerkToken: "fresh-clerk-token", - connection: savedDpopConnection, - }); - const persistedConnection = mocks.saveConnection.mock.calls[0]?.[0]; - expect(persistedConnection).toMatchObject({ - ...savedDpopConnection, - displayUrl: refreshedConnection.displayUrl, - httpBaseUrl: refreshedConnection.httpBaseUrl, - wsBaseUrl: refreshedConnection.wsBaseUrl, - }); - expect(persistedConnection).not.toHaveProperty("dpopAccessToken"); - expect(mocks.createDpopProof).toHaveBeenCalledWith({ - method: "POST", - url: "https://rotated-desktop.example/api/auth/websocket-ticket", - accessToken: "fresh-environment-dpop-token", - }); - expect(mocks.resolveRemoteDpopWebSocketConnectionUrl).toHaveBeenCalledWith({ - wsBaseUrl: refreshedConnection.wsBaseUrl, - httpBaseUrl: refreshedConnection.httpBaseUrl, - accessToken: "fresh-environment-dpop-token", - dpopProof: "dpop-proof", - }); - }), - ); - - it.effect("fails interactive connects when the managed endpoint bootstrap fails", () => - Effect.gen(function* () { - mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( - new Error("bootstrap failed"), - ); - mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ - connection: mocks.sessionConnection, - } as never); - - const result = yield* Effect.exit(connectSavedEnvironment(connection)); - - expect(result._tag).toBe("Failure"); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); - expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); - }), - ); - - it.effect("can suppress bootstrap failures during best-effort startup reconnect", () => - Effect.gen(function* () { - mocks.environmentConnection.ensureBootstrapped.mockRejectedValueOnce( - new Error("bootstrap failed"), - ); - mocks.removeEnvironmentSession.mockReturnValueOnce(null).mockReturnValueOnce({ - connection: mocks.sessionConnection, - } as never); - - yield* connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }); - - expect(mocks.saveConnection).not.toHaveBeenCalled(); - expect(mocks.environmentConnection.ensureBootstrapped).toHaveBeenCalledTimes(1); - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.subscribeTerminalMetadata).not.toHaveBeenCalled(); - expect(mocks.registerAgentAwarenessConnection).not.toHaveBeenCalled(); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - }), - ); - - it.effect("reconnects a stale saved environment session after app resume", () => - Effect.gen(function* () { - yield* connectSavedEnvironment(connection); - vi.clearAllMocks(); - mocks.getEnvironmentSession.mockReturnValue({ - client: mocks.sessionClient, - connection: mocks.sessionConnection, - } as never); - - reconnectEnvironmentConnectionsAfterAppResume("test"); - - yield* Effect.promise(() => - vi.waitFor(() => { - expect(mocks.sessionConnection.reconnect).toHaveBeenCalledTimes(1); - }), - ); - expect(mocks.shellSnapshotMarkPending).toHaveBeenCalledWith({ - environmentId: connection.environmentId, - }); - expect(mocks.environmentRuntimePatch).toHaveBeenCalledWith( - { environmentId: connection.environmentId }, - expect.any(Function), - ); - }), - ); - - it.effect("disconnects and removes persisted managed endpoint state when requested", () => - Effect.gen(function* () { - mocks.removeEnvironmentSession.mockReturnValue({ - connection: mocks.sessionConnection, - } as never); - - yield* disconnectEnvironment(connection.environmentId, { removeSaved: true }); - - expect(mocks.sessionConnection.dispose).toHaveBeenCalledTimes(1); - expect(mocks.unregisterAgentAwarenessConnection).toHaveBeenCalledWith( - connection.environmentId, - ); - expect(mocks.clearSavedConnection).toHaveBeenCalledWith(connection.environmentId); - expect(mocks.clearCachedShellSnapshot).toHaveBeenCalledWith(connection.environmentId); - expect(mocks.clearCachedShellSnapshotMetadata).toHaveBeenCalledWith(connection.environmentId); - }), - ); -}); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index b7584858dc4..6c37a0be813 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,90 +1,25 @@ import { useAtomValue } from "@effect/atom-react"; -import { useCallback, useEffect, useMemo } from "react"; -import { Alert, AppState } from "react-native"; - -import { - type EnvironmentRuntimeState, - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - createKnownEnvironment, - createWsRpcClient, - EnvironmentConnectionState, - ManagedRelayDpopSigner, - WsTransport, - remoteEndpointUrl, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, - waitForManagedRelayClerkToken, -} from "@t3tools/client-runtime"; +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Order from "effect/Order"; +import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { pipe } from "effect/Function"; import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; +import { Alert } from "react-native"; + +import { useEnvironmentServerConfig } from "../state/entities"; +import { useConnectionController } from "../features/connection/useConnectionController"; +import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { - type SavedRemoteConnection, - bootstrapRemoteConnection, - isRelayManagedConnection, - toStableSavedRemoteConnection, -} from "../lib/connection"; -import { refreshCloudEnvironmentConnection } from "../features/cloud/linkEnvironment"; -import { terminalDebugLog } from "../features/terminal/terminalDebugLog"; -import { - clearCachedShellSnapshot, - clearSavedConnection, - loadCachedShellSnapshot, - loadSavedConnections, - saveCachedShellSnapshot, - saveConnection, -} from "../lib/storage"; + projectEnvironmentPresentation, + type EnvironmentPresentation, +} from "../state/environments"; +import { useWorkspaceState } from "../state/workspace"; +import type { SavedRemoteConnection } from "../lib/connection"; import { appAtomRegistry } from "./atom-registry"; -import { mobileRuntime } from "../lib/runtime"; -import { - drainEnvironmentSessions, - getEnvironmentSession, - notifyEnvironmentConnectionListeners, - removeEnvironmentSession, - setEnvironmentSession, -} from "./environment-session-registry"; -import { type ConnectedEnvironmentSummary } from "./remote-runtime-types"; -import { - invalidateSourceControlDiscoveryForEnvironment, - resetSourceControlDiscoveryState, -} from "./use-source-control-discovery"; -import { - registerAgentAwarenessConnection, - unregisterAgentAwarenessConnection, - unregisterAllAgentAwarenessConnections, -} from "../features/agent-awareness/remoteRegistration"; -import { environmentRuntimeManager, useEnvironmentRuntimeStates } from "./use-environment-runtime"; -import { - clearCachedShellSnapshotMetadata, - hydrateCachedShellSnapshot, - markShellSnapshotLive, - shellSnapshotManager, -} from "./use-shell-snapshot"; -import { subscribeTerminalMetadata, terminalSessionManager } from "./use-terminal-session"; - -const terminalMetadataUnsubscribers = new Map void>(); -const environmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); -const SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS = 8_000; -const APP_RESUME_RECONNECT_COOLDOWN_MS = 2_000; -let lastAppResumeReconnectAt = Number.NEGATIVE_INFINITY; - -interface RemoteEnvironmentLocalState { - readonly isLoadingSavedConnection: boolean; - readonly connectionPairingUrl: string; - readonly pendingConnectionError: string | null; - readonly savedConnectionsById: Record; -} - -const isLoadingSavedConnectionAtom = Atom.make(true).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:is-loading-saved-connection"), -); +import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types"; +import { environmentSession, usePreparedConnection } from "./session"; +import { environmentCatalog } from "../connection/catalog"; const connectionPairingUrlAtom = Atom.make("").pipe( Atom.keepAlive, @@ -96,680 +31,192 @@ const pendingConnectionErrorAtom = Atom.make(null).pipe( Atom.withLabel("mobile:pending-connection-error"), ); -const savedConnectionsByIdAtom = Atom.make>({}).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:saved-connections"), -); - -function getSavedConnectionsById(): Record { - return appAtomRegistry.get(savedConnectionsByIdAtom); -} - -function setIsLoadingSavedConnection(value: boolean): void { - appAtomRegistry.set(isLoadingSavedConnectionAtom, value); -} - -function setConnectionPairingUrl(pairingUrl: string): void { - appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); -} - -function clearConnectionPairingUrl(): void { - appAtomRegistry.set(connectionPairingUrlAtom, ""); -} - export function setPendingConnectionError(message: string | null): void { appAtomRegistry.set(pendingConnectionErrorAtom, message); } -function clearPendingConnectionError(): void { - appAtomRegistry.set(pendingConnectionErrorAtom, null); -} +function toSavedConnection( + environment: EnvironmentPresentation, + prepared: Option.Option, +): SavedRemoteConnection { + const displayUrl = environment.displayUrl ?? ""; + const active = Option.getOrNull(prepared); + const httpBaseUrl = active?.httpBaseUrl ?? displayUrl; + const socketUrl = active?.socketUrl ?? ""; + const wsBaseUrl = + socketUrl === "" + ? displayUrl.startsWith("https://") + ? displayUrl.replace(/^https:/, "wss:") + : displayUrl.replace(/^http:/, "ws:") + : new URL(socketUrl).origin; + const authorization = active?.httpAuthorization ?? null; -function replaceSavedConnections(connections: Record): void { - appAtomRegistry.set(savedConnectionsByIdAtom, connections); -} - -function upsertSavedConnection(connection: SavedRemoteConnection): void { - const current = appAtomRegistry.get(savedConnectionsByIdAtom); - appAtomRegistry.set(savedConnectionsByIdAtom, { - ...current, - [connection.environmentId]: connection, - }); + return { + environmentId: environment.environmentId, + environmentLabel: environment.label, + pairingUrl: displayUrl, + displayUrl, + httpBaseUrl, + wsBaseUrl, + bearerToken: authorization?._tag === "Bearer" ? authorization.token : null, + ...(environment.relayManaged + ? { + authenticationMethod: "dpop" as const, + relayManaged: true as const, + ...(authorization?._tag === "Dpop" ? { dpopAccessToken: authorization.accessToken } : {}), + } + : { authenticationMethod: "bearer" as const }), + }; } -function removeSavedConnection(environmentId: EnvironmentId): void { - const current = appAtomRegistry.get(savedConnectionsByIdAtom); - const next = { ...current }; - delete next[environmentId]; - appAtomRegistry.set(savedConnectionsByIdAtom, next); +const savedConnectionsByIdAtom = Atom.make((get) => { + const presentationById = get(environmentPresentations.presentationsAtom); + return Object.fromEntries( + [...presentationById.entries()].map(([environmentId, presentation]) => [ + environmentId, + toSavedConnection( + projectEnvironmentPresentation(environmentId, presentation), + get(environmentSession.preparedConnectionValueAtom(environmentId)), + ), + ]), + ) as Record; +}).pipe(Atom.withLabel("mobile:saved-connections-by-id")); + +function toRuntimeState( + environment: EnvironmentPresentation, + serverConfig: ServerConfig | null, +): EnvironmentRuntimeState { + return { + connectionState: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + serverConfig, + }; } -function useRemoteEnvironmentLocalState(): RemoteEnvironmentLocalState { - const isLoadingSavedConnection = useAtomValue(isLoadingSavedConnectionAtom); - const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); - const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); +export function useSavedRemoteConnections() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom); - return useMemo( - () => ({ - isLoadingSavedConnection, - connectionPairingUrl, - pendingConnectionError, - savedConnectionsById, - }), - [connectionPairingUrl, isLoadingSavedConnection, pendingConnectionError, savedConnectionsById], - ); -} - -function setEnvironmentConnectionStatus( - environmentId: EnvironmentId, - state: ConnectedEnvironmentSummary["connectionState"], - error?: string | null, -) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionState: state, - connectionError: error === undefined ? current.connectionError : error, - })); -} - -function fromPromise(tryPromise: () => Promise): Effect.Effect { - return Effect.tryPromise({ - try: tryPromise, - catch: (cause) => cause, - }); -} - -export function disconnectEnvironment( - environmentId: EnvironmentId, - options?: { - readonly preserveShellSnapshot?: boolean; - readonly removeSaved?: boolean; - readonly preserveConnectionAttempt?: boolean; - }, -): Effect.Effect { - return Effect.gen(function* () { - if (!options?.preserveConnectionAttempt) { - environmentConnectionAttempts.cancel(environmentId); - } - - const session = removeEnvironmentSession(environmentId); - notifyEnvironmentConnectionListeners(); - if (session) { - yield* fromPromise(() => session.connection.dispose()); - } - terminalMetadataUnsubscribers.get(environmentId)?.(); - terminalMetadataUnsubscribers.delete(environmentId); - unregisterAgentAwarenessConnection(environmentId); - if (!options?.preserveShellSnapshot) { - shellSnapshotManager.invalidate({ environmentId }); - } - invalidateSourceControlDiscoveryForEnvironment(environmentId); - terminalSessionManager.invalidateEnvironment(environmentId); - environmentRuntimeManager.invalidate({ environmentId }); - - if (options?.removeSaved) { - yield* Effect.all( - [ - fromPromise(() => clearSavedConnection(environmentId)), - fromPromise(() => clearCachedShellSnapshot(environmentId)), - ], - { concurrency: 2 }, - ); - clearCachedShellSnapshotMetadata(environmentId); - removeSavedConnection(environmentId); - } - }); -} - -export function connectSavedEnvironment( - connection: SavedRemoteConnection, - options?: { readonly persist?: boolean; readonly suppressBootstrapError?: boolean }, -): Effect.Effect { - return Effect.gen(function* () { - const connectionAttempt = environmentConnectionAttempts.begin(connection.environmentId); - const isCurrentAttempt = connectionAttempt.isCurrent; - let activeConnection = connection; - let initialDpopAccessToken = - options?.persist === false ? undefined : connection.dpopAccessToken; - - yield* disconnectEnvironment(connection.environmentId, { - preserveShellSnapshot: true, - preserveConnectionAttempt: true, - }); - if (!isCurrentAttempt()) { - return; - } - - if (options?.persist !== false) { - yield* fromPromise(() => saveConnection(toStableSavedRemoteConnection(connection))); - if (!isCurrentAttempt()) { - return; - } - } - - upsertSavedConnection(toStableSavedRemoteConnection(connection)); - setEnvironmentConnectionStatus(connection.environmentId, "connecting", null); - shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - - const transport = new WsTransport( - () => - mobileRuntime.runPromise( - isRelayManagedConnection(connection) - ? Effect.gen(function* () { - let dpopAccessToken = initialDpopAccessToken; - initialDpopAccessToken = undefined; - if (!dpopAccessToken) { - const clerkToken = yield* waitForManagedRelayClerkToken(appAtomRegistry); - const refreshedConnection = yield* refreshCloudEnvironmentConnection({ - clerkToken, - connection: activeConnection, - }); - const stableConnection = toStableSavedRemoteConnection(refreshedConnection); - activeConnection = refreshedConnection; - if (isCurrentAttempt()) { - yield* fromPromise(() => saveConnection(stableConnection)); - upsertSavedConnection(stableConnection); - } - dpopAccessToken = refreshedConnection.dpopAccessToken; - } - if (!dpopAccessToken) { - return yield* Effect.fail( - new Error("Managed environment connection did not return a DPoP access token."), - ); - } - const signer = yield* ManagedRelayDpopSigner; - const dpop = yield* signer.createProof({ - method: "POST", - url: remoteEndpointUrl( - activeConnection.httpBaseUrl, - "/api/auth/websocket-ticket", - ), - accessToken: dpopAccessToken, - }); - return yield* resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: activeConnection.wsBaseUrl, - httpBaseUrl: activeConnection.httpBaseUrl, - accessToken: dpopAccessToken, - dpopProof: dpop, - }); - }) - : resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: connection.wsBaseUrl, - httpBaseUrl: connection.httpBaseUrl, - bearerToken: connection.bearerToken ?? "", - }), - ), - { - onAttempt: () => { - if (!isCurrentAttempt()) { - return; - } - - environmentRuntimeManager.patch( - { environmentId: connection.environmentId }, - (previous) => { - const nextState = - previous.connectionState === "ready" || previous.connectionState === "reconnecting" - ? "reconnecting" - : "connecting"; - const keepSettledFailure = - previous.connectionState === "disconnected" && previous.connectionError !== null; - return { - ...previous, - connectionState: keepSettledFailure ? "disconnected" : nextState, - connectionError: keepSettledFailure ? previous.connectionError : null, - }; - }, - ); - }, - onError: (message) => { - if (isCurrentAttempt()) { - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); - } - }, - onClose: (details) => { - if (!isCurrentAttempt()) { - return; - } - - const reason = - details.reason.trim().length > 0 - ? details.reason - : details.code === 1000 - ? null - : `Remote connection closed (${details.code}).`; - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", reason); - }, - }, - ); - - const client = createWsRpcClient(transport); - const environmentConnection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...createKnownEnvironment({ - id: connection.environmentId, - label: connection.environmentLabel, - source: "manual", - target: { - httpBaseUrl: connection.httpBaseUrl, - wsBaseUrl: connection.wsBaseUrl, - }, - }), - environmentId: connection.environmentId, - }, - client, - applyShellEvent: (event, environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.applyEvent({ environmentId }, event); - } - }, - syncShellSnapshot: (snapshot, environmentId) => { - if (!isCurrentAttempt()) { - return; - } - - shellSnapshotManager.syncSnapshot({ environmentId }, snapshot); - markShellSnapshotLive(environmentId); - void saveCachedShellSnapshot(environmentId, snapshot).catch(() => undefined); - environmentRuntimeManager.patch({ environmentId }, (runtime) => ({ - ...runtime, - connectionState: "ready", - connectionError: null, - })); - }, - onShellResubscribe: (environmentId) => { - if (isCurrentAttempt()) { - shellSnapshotManager.markPending({ environmentId }); - } - }, - onConfigSnapshot: (serverConfig) => { - if (isCurrentAttempt()) { - environmentRuntimeManager.patch( - { environmentId: connection.environmentId }, - (runtime) => ({ - ...runtime, - serverConfig, - }), - ); - } - }, - }); - - if (!isCurrentAttempt()) { - yield* fromPromise(() => environmentConnection.dispose()); - return; - } - - setEnvironmentSession(connection.environmentId, { - client, - connection: environmentConnection, - }); - - const bootstrap = fromPromise(() => environmentConnection.ensureBootstrapped()).pipe( - Effect.timeoutOption(Duration.millis(SAVED_CONNECTION_BOOTSTRAP_TIMEOUT_MS)), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail(new Error("Environment did not respond before the connection timeout.")), - onSome: Effect.succeed, - }), - ), - Effect.tapError((error: unknown) => - isCurrentAttempt() - ? Effect.gen(function* () { - setEnvironmentConnectionStatus( - connection.environmentId, - "disconnected", - error instanceof Error ? error.message : "Failed to bootstrap remote connection.", - ); - const pendingSession = removeEnvironmentSession(connection.environmentId); - notifyEnvironmentConnectionListeners(); - if (pendingSession) { - yield* fromPromise(() => pendingSession.connection.dispose()); - } - }) - : Effect.void, - ), - ); - const bootstrapped = yield* options?.suppressBootstrapError - ? bootstrap.pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - : bootstrap.pipe(Effect.as(true)); - - if (!bootstrapped || !isCurrentAttempt()) { - return; - } - - terminalMetadataUnsubscribers.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, - client, - }), - ); - terminalDebugLog("registry:terminal-metadata-subscribed", { - environmentId: connection.environmentId, - }); - registerAgentAwarenessConnection(toStableSavedRemoteConnection(activeConnection)); - notifyEnvironmentConnectionListeners(); - }); + return { + isLoadingSavedConnection: !catalog.isReady, + savedConnectionsById, + }; } -export function reconnectEnvironmentConnectionsAfterAppResume(reason: string): void { - const now = Date.now(); - if (now - lastAppResumeReconnectAt < APP_RESUME_RECONNECT_COOLDOWN_MS) { - return; - } - - for (const connection of Object.values(getSavedConnectionsById())) { - const session = getEnvironmentSession(connection.environmentId); - if (session?.client.isHeartbeatFresh()) { - continue; - } - - lastAppResumeReconnectAt = now; - terminalDebugLog("registry:app-resume-reconnect", { - environmentId: connection.environmentId, - reason, - hasSession: session !== null, - }); - - if (!session) { - void mobileRuntime - .runPromise( - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ) - .catch((error: unknown) => { - terminalDebugLog("registry:app-resume-reconnect-failed", { - environmentId: connection.environmentId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - continue; - } - - setEnvironmentConnectionStatus(connection.environmentId, "reconnecting", null); - shellSnapshotManager.markPending({ environmentId: connection.environmentId }); - void session.connection.reconnect().catch((error: unknown) => { - const message = - error instanceof Error ? error.message : "Failed to reconnect remote environment."; - setEnvironmentConnectionStatus(connection.environmentId, "disconnected", message); - terminalDebugLog("registry:app-resume-reconnect-failed", { - environmentId: connection.environmentId, - reason, - error: message, - }); - }); +export function useSavedRemoteConnection( + environmentId: EnvironmentId | null, +): SavedRemoteConnection | null { + const { presentation } = useEnvironmentPresentation(environmentId); + const prepared = usePreparedConnection(environmentId); + if (environmentId === null || presentation === null) { + return null; } + return toSavedConnection(projectEnvironmentPresentation(environmentId, presentation), prepared); } -function subscribeAppResumeReconnects(): () => void { - let previousAppState = AppState.currentState; - const subscription = AppState.addEventListener("change", (nextAppState) => { - const wasInactive = previousAppState !== "active"; - previousAppState = nextAppState; - if (nextAppState === "active" && wasInactive) { - reconnectEnvironmentConnectionsAfterAppResume("appstate"); - } - }); - - return () => subscription.remove(); -} - -const environmentsSortOrder = Order.mapInput( - Order.Struct({ - environmentLabel: Order.String, - }), - (environment: ConnectedEnvironmentSummary) => ({ - environmentLabel: environment.environmentLabel, - }), -); - -function deriveConnectedEnvironments( - savedConnectionsById: Record, - environmentStateById: Record, -): ReadonlyArray { - return Arr.sort( - Object.values(savedConnectionsById).map((connection) => { - const runtime = environmentStateById[connection.environmentId]; - return { - environmentId: connection.environmentId, - environmentLabel: connection.environmentLabel, - displayUrl: connection.displayUrl, - isRelayManaged: isRelayManagedConnection(connection), - connectionState: runtime?.connectionState ?? "idle", - connectionError: runtime?.connectionError ?? null, - }; - }), - environmentsSortOrder, - ); -} - -export function useRemoteEnvironmentBootstrap() { - useEffect(() => { - let cancelled = false; - const unsubscribeAppResumeReconnects = subscribeAppResumeReconnects(); - - void (async () => { - try { - const connections = await loadSavedConnections(); - if (cancelled) { - return; - } - - replaceSavedConnections( - Object.fromEntries( - connections.map((connection) => [connection.environmentId, connection]), - ), - ); - - setIsLoadingSavedConnection(false); - - await Promise.all( - connections.map(async (connection) => { - const cached = await loadCachedShellSnapshot(connection.environmentId); - if (!cancelled && cached) { - hydrateCachedShellSnapshot(cached); - } - }), - ); - - if (cancelled) { - return; - } - - await mobileRuntime.runPromise( - Effect.all( - connections.map((connection) => - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ), - { concurrency: "unbounded" }, - ), - ); - } catch { - if (!cancelled) { - setIsLoadingSavedConnection(false); - } - } - })(); - - return () => { - cancelled = true; - unsubscribeAppResumeReconnects(); - for (const session of drainEnvironmentSessions()) { - void session.connection.dispose(); - } - for (const unsubscribe of terminalMetadataUnsubscribers.values()) { - unsubscribe(); - } - terminalMetadataUnsubscribers.clear(); - environmentConnectionAttempts.clear(); - unregisterAllAgentAwarenessConnections(); - environmentRuntimeManager.invalidate(); - shellSnapshotManager.invalidate(); - resetSourceControlDiscoveryState(); - terminalSessionManager.invalidate(); - notifyEnvironmentConnectionListeners(); - }; - }, []); -} - -export function useRemoteEnvironmentState() { - const state = useRemoteEnvironmentLocalState(); - const environmentStateById = useEnvironmentRuntimeStates( - Object.values(state.savedConnectionsById).map((connection) => connection.environmentId), - ); - - return useMemo( - () => ({ - ...state, - environmentStateById, - }), - [environmentStateById, state], - ); +export function useRemoteEnvironmentRuntime( + environmentId: EnvironmentId | null, +): EnvironmentRuntimeState | null { + const { presentation } = useEnvironmentPresentation(environmentId); + const serverConfig = useEnvironmentServerConfig(environmentId); + if (environmentId === null || presentation === null) { + return null; + } + return toRuntimeState(projectEnvironmentPresentation(environmentId, presentation), serverConfig); } export function useRemoteConnectionStatus() { - const { environmentStateById, pendingConnectionError, savedConnectionsById } = - useRemoteEnvironmentState(); - - const connectedEnvironments = useMemo( - () => deriveConnectedEnvironments(savedConnectionsById, environmentStateById), - [environmentStateById, savedConnectionsById], - ); - - const connectionState = useMemo(() => { - if (connectedEnvironments.length === 0) { - return "idle"; - } - if (connectedEnvironments.some((environment) => environment.connectionState === "ready")) { - return "ready"; - } - if ( - connectedEnvironments.some((environment) => environment.connectionState === "reconnecting") - ) { - return "reconnecting"; - } - if (connectedEnvironments.some((environment) => environment.connectionState === "connecting")) { - return "connecting"; - } - return "disconnected"; - }, [connectedEnvironments]); - - const connectionError = useMemo( + const workspace = useWorkspaceState(); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); + const connectedEnvironments = useMemo>( () => - pipe( - Arr.appendAll( - [pendingConnectionError], - Arr.map(connectedEnvironments, (environment) => environment.connectionError), - ), - Arr.findFirst((value) => value !== null), - Option.getOrNull, - ), - [connectedEnvironments, pendingConnectionError], + workspace.environments.map((environment) => ({ + environmentId: environment.environmentId, + environmentLabel: environment.environmentLabel, + displayUrl: environment.displayUrl, + isRelayManaged: environment.isRelayManaged, + connectionState: environment.connectionState, + connectionError: environment.connectionError, + connectionErrorTraceId: environment.connectionErrorTraceId, + })), + [workspace.environments], ); return { connectedEnvironments, - connectionState, - connectionError, + connectionState: workspace.state.connectionState, + connectionError: pendingConnectionError ?? workspace.state.connectionError, }; } export function useRemoteConnections() { - const { connectionPairingUrl, pendingConnectionError } = useRemoteEnvironmentState(); + const controller = useConnectionController(); + const connectionPairingUrl = useAtomValue(connectionPairingUrlAtom); + const pendingConnectionError = useAtomValue(pendingConnectionErrorAtom); const { connectedEnvironments, connectionError, connectionState } = useRemoteConnectionStatus(); + const onChangeConnectionPairingUrl = useCallback((pairingUrl: string) => { + appAtomRegistry.set(connectionPairingUrlAtom, pairingUrl); + }, []); + const onConnectPress = useCallback( async (pairingUrl?: string) => { try { const nextPairingUrl = pairingUrl ?? connectionPairingUrl; - const connection = await bootstrapRemoteConnection({ pairingUrl: nextPairingUrl }); - clearPendingConnectionError(); - await mobileRuntime.runPromise(connectSavedEnvironment(connection)); - clearConnectionPairingUrl(); + setPendingConnectionError(null); + await controller.connectPairingUrl(nextPairingUrl); + appAtomRegistry.set(connectionPairingUrlAtom, ""); } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to pair with the environment.", - ); + const message = + error instanceof Error ? error.message : "Failed to pair with the environment."; + setPendingConnectionError(message); throw error; } }, - [connectionPairingUrl], + [connectionPairingUrl, controller], ); + const onReconnectEnvironment = useCallback( + (environmentId: EnvironmentId) => { + void controller.retryEnvironment(environmentId); + }, + [controller], + ); const onUpdateEnvironment = useCallback( - async ( + ( environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, - ) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection || isRelayManagedConnection(connection)) { + ) => controller.updateEnvironment(environmentId, updates), + [controller], + ); + + const onRemoveEnvironmentPress = useCallback( + (environmentId: EnvironmentId) => { + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === environmentId, + ); + if (!environment) { return; } - - const updated: SavedRemoteConnection = { - ...connection, - environmentLabel: updates.label.trim() || connection.environmentLabel, - displayUrl: updates.displayUrl.trim() || connection.displayUrl, - }; - - await saveConnection(updated); - upsertSavedConnection(updated); + Alert.alert( + "Remove environment?", + `Disconnect and forget ${environment.environmentLabel} on this device.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Remove", + style: "destructive", + onPress: () => { + void controller.removeEnvironment(environmentId); + }, + }, + ], + ); }, - [], + [connectedEnvironments, controller], ); - const onReconnectEnvironment = useCallback((environmentId: EnvironmentId) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { - return; - } - void mobileRuntime - .runPromise( - connectSavedEnvironment(connection, { - persist: false, - suppressBootstrapError: true, - }), - ) - .catch(() => undefined); - }, []); - - const onRemoveEnvironmentPress = useCallback((environmentId: EnvironmentId) => { - const connection = getSavedConnectionsById()[environmentId]; - if (!connection) { - return; - } - - Alert.alert( - "Remove environment?", - `Disconnect and forget ${connection.environmentLabel} on this device.`, - [ - { text: "Cancel", style: "cancel" }, - { - text: "Remove", - style: "destructive", - onPress: () => { - void mobileRuntime - .runPromise(disconnectEnvironment(environmentId, { removeSaved: true })) - .catch(() => undefined); - }, - }, - ], - ); - }, []); - return { connectionPairingUrl, connectionState, @@ -777,7 +224,7 @@ export function useRemoteConnections() { pairingConnectionError: pendingConnectionError, connectedEnvironments, connectedEnvironmentCount: connectedEnvironments.length, - onChangeConnectionPairingUrl: setConnectionPairingUrl, + onChangeConnectionPairingUrl, onConnectPress, onReconnectEnvironment, onUpdateEnvironment, diff --git a/apps/mobile/src/state/use-selected-thread-commands.ts b/apps/mobile/src/state/use-selected-thread-commands.ts index a28d33c65d1..c551a9f089f 100644 --- a/apps/mobile/src/state/use-selected-thread-commands.ts +++ b/apps/mobile/src/state/use-selected-thread-commands.ts @@ -1,16 +1,13 @@ +import { useAtomSet } from "@effect/atom-react"; import { useCallback } from "react"; import { - CommandId, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, } from "@t3tools/contracts"; -import { uuidv4 } from "../lib/uuid"; -import { environmentRuntimeManager } from "./use-environment-runtime"; -import { getEnvironmentClient } from "./environment-session-registry"; -import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; +import { threadEnvironment } from "../state/threads"; import { useThreadSelection } from "./use-thread-selection"; export function useSelectedThreadCommands(input: { @@ -19,43 +16,18 @@ export function useSelectedThreadCommands(input: { readonly cwd?: string | null; }) => Promise; }) { + const updateMetadata = useAtomSet(threadEnvironment.updateMetadata, { mode: "promise" }); + const setRuntimeMode = useAtomSet(threadEnvironment.setRuntimeMode, { mode: "promise" }); + const setInteractionMode = useAtomSet(threadEnvironment.setInteractionMode, { mode: "promise" }); + const interruptTurn = useAtomSet(threadEnvironment.interruptTurn, { mode: "promise" }); const { refreshSelectedThreadGitStatus } = input; const { selectedThread } = useThreadSelection(); - const { savedConnectionsById } = useRemoteEnvironmentState(); const onRefresh = useCallback(async () => { - const targets = selectedThread - ? [selectedThread.environmentId] - : Object.values(savedConnectionsById).map((connection) => connection.environmentId); - - await Promise.all( - targets.map(async (environmentId) => { - const client = getEnvironmentClient(environmentId); - if (!client) { - return; - } - - try { - const serverConfig = await client.server.getConfig(); - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - serverConfig, - connectionError: null, - })); - } catch (error) { - environmentRuntimeManager.patch({ environmentId }, (current) => ({ - ...current, - connectionError: - error instanceof Error ? error.message : "Failed to refresh remote data.", - })); - } - }), - ); - if (selectedThread) { await refreshSelectedThreadGitStatus({ quiet: true }); } - }, [refreshSelectedThreadGitStatus, savedConnectionsById, selectedThread]); + }, [refreshSelectedThreadGitStatus, selectedThread]); const onUpdateThreadModelSelection = useCallback( async (modelSelection: ModelSelection) => { @@ -63,19 +35,15 @@ export function useSelectedThreadCommands(input: { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - modelSelection, + await updateMetadata({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + modelSelection, + }, }); }, - [selectedThread], + [selectedThread, updateMetadata], ); const onUpdateThreadRuntimeMode = useCallback( @@ -84,20 +52,15 @@ export function useSelectedThreadCommands(input: { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.runtime-mode.set", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - runtimeMode, - createdAt: new Date().toISOString(), + await setRuntimeMode({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + runtimeMode, + }, }); }, - [selectedThread], + [selectedThread, setRuntimeMode], ); const onUpdateThreadInteractionMode = useCallback( @@ -106,20 +69,15 @@ export function useSelectedThreadCommands(input: { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.interaction-mode.set", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - interactionMode, - createdAt: new Date().toISOString(), + await setInteractionMode({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + interactionMode, + }, }); }, - [selectedThread], + [selectedThread, setInteractionMode], ); const onStopThread = useCallback(async () => { @@ -127,11 +85,6 @@ export function useSelectedThreadCommands(input: { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - if ( selectedThread.session?.status !== "running" && selectedThread.session?.status !== "starting" @@ -139,16 +92,16 @@ export function useSelectedThreadCommands(input: { return; } - await client.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - ...(selectedThread.session?.activeTurnId - ? { turnId: selectedThread.session.activeTurnId } - : {}), - createdAt: new Date().toISOString(), + await interruptTurn({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + ...(selectedThread.session?.activeTurnId + ? { turnId: selectedThread.session.activeTurnId } + : {}), + }, }); - }, [selectedThread]); + }, [interruptTurn, selectedThread]); const onRenameThread = useCallback( async (title: string) => { @@ -156,24 +109,20 @@ export function useSelectedThreadCommands(input: { return; } - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return; - } - const trimmed = title.trim(); if (!trimmed || trimmed === selectedThread.title) { return; } - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: selectedThread.id, - title: trimmed, + await updateMetadata({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + title: trimmed, + }, }); }, - [selectedThread], + [selectedThread, updateMetadata], ); return { diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 18860935f36..d1791dcc5c1 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -1,32 +1,54 @@ -import { useCallback, useEffect } from "react"; +import { useAtomSet } from "@effect/atom-react"; +import { useCallback, useEffect, useMemo } from "react"; +import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { - EnvironmentScopedProjectShell, - EnvironmentScopedThreadShell, - type VcsRef, type GitActionRequestInput, -} from "@t3tools/client-runtime"; -import { CommandId, type GitRunStackedActionResult } from "@t3tools/contracts"; + type VcsActionOperation, + type VcsRef, +} from "@t3tools/client-runtime/state/vcs"; +import type { GitRunStackedActionResult } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import { gitEnvironment } from "../state/git"; +import { useBranches } from "../state/queries"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; import { uuidv4 } from "../lib/uuid"; -import { getEnvironmentClient } from "./environment-session-registry"; import { setPendingConnectionError } from "./use-remote-environment-registry"; -import { vcsActionManager, showGitActionResult } from "./use-vcs-action-state"; -import { vcsRefManager } from "./use-vcs-refs"; -import { vcsStatusManager } from "./use-vcs-status"; +import { + beginVcsAction, + completeVcsAction, + failVcsAction, + showGitActionResult, +} from "./use-vcs-action-state"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; export function useSelectedThreadGitActions() { + const runStackedAction = useAtomSet(gitEnvironment.runStackedAction, { mode: "promise" }); + const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, { mode: "promise" }); + const refreshStatus = useAtomSet(vcsEnvironment.refreshStatus, { mode: "promise" }); + const switchRef = useAtomSet(vcsEnvironment.switchRef, { mode: "promise" }); + const createRef = useAtomSet(vcsEnvironment.createRef, { mode: "promise" }); + const createWorktree = useAtomSet(vcsEnvironment.createWorktree, { mode: "promise" }); + const pull = useAtomSet(vcsEnvironment.pull, { mode: "promise" }); const { selectedThread, selectedThreadProject } = useThreadSelection(); const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree(); const selectedThreadGitRootCwd = selectedThreadProject?.workspaceRoot ?? null; - + const branchTarget = useMemo( + () => ({ + environmentId: selectedThread?.environmentId ?? null, + cwd: selectedThreadGitRootCwd, + query: null, + }), + [selectedThread?.environmentId, selectedThreadGitRootCwd], + ); + const branchState = useBranches(branchTarget); const updateThreadGitContext = useCallback( async ( thread: NonNullable, @@ -35,20 +57,16 @@ export function useSelectedThreadGitActions() { readonly worktreePath?: string | null; }, ) => { - const client = getEnvironmentClient(thread.environmentId); - if (!client) { - return; - } - - await client.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: CommandId.make(uuidv4()), - threadId: thread.id, - ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), - ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + await updateThreadMetadata({ + environmentId: thread.environmentId, + input: { + threadId: thread.id, + ...(nextState.branch !== undefined ? { branch: nextState.branch } : {}), + ...(nextState.worktreePath !== undefined ? { worktreePath: nextState.worktreePath } : {}), + }, }); }, - [], + [updateThreadMetadata], ); const refreshSelectedThreadGitStatus = useCallback( @@ -62,61 +80,72 @@ export function useSelectedThreadGitActions() { return null; } + const target = { environmentId: selectedThread.environmentId, cwd }; + if (!options?.quiet) { + beginVcsAction(target, { + operation: "refresh_status", + label: "Refreshing source control status", + }); + } try { - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return null; + const result = await refreshStatus({ + environmentId: selectedThread.environmentId, + input: { cwd }, + }); + if (!options?.quiet) { + completeVcsAction(target); } - - const status = await vcsActionManager.refreshStatus( - { environmentId: selectedThread.environmentId, cwd }, - { ...client.vcs, runChangeRequest: client.git.runStackedAction }, - options, - ); setPendingConnectionError(null); - return status; + return result; } catch (error) { + if (!options?.quiet) { + failVcsAction(target, "refresh_status", error); + } const message = error instanceof Error ? error.message : "Failed to refresh git status."; setPendingConnectionError(message); return null; } }, - [selectedThread, selectedThreadCwd, selectedThreadProject], + [refreshStatus, selectedThread, selectedThreadCwd, selectedThreadProject], ); useEffect(() => { if (!selectedThread || !selectedThreadProject) { return; } - void refreshSelectedThreadGitStatus({ quiet: true }); }, [refreshSelectedThreadGitStatus, selectedThread, selectedThreadProject]); const runSelectedThreadGitMutation = useCallback( async ( - operation: (input: { - readonly thread: EnvironmentScopedThreadShell; - readonly project: EnvironmentScopedProjectShell; + operation: VcsActionOperation, + label: string, + execute: (input: { + readonly thread: EnvironmentThreadShell; + readonly project: EnvironmentProject; readonly cwd: string; }) => Promise, ): Promise => { - if (!selectedThread || !selectedThreadProject) { - return null; - } - - const cwd = selectedThreadCwd; - if (!cwd) { + if (!selectedThread || !selectedThreadProject || !selectedThreadCwd) { return null; } + const target = { + environmentId: selectedThread.environmentId, + cwd: selectedThreadCwd, + }; + beginVcsAction(target, { operation, label }); try { setPendingConnectionError(null); - return await operation({ + const result = await execute({ thread: selectedThread, project: selectedThreadProject, - cwd, + cwd: selectedThreadCwd, }); + completeVcsAction(target); + return result; } catch (error) { + failVcsAction(target, operation, error); const message = error instanceof Error ? error.message : "Git action failed."; setPendingConnectionError(message); showGitActionResult({ type: "error", title: "Git action failed", description: message }); @@ -127,37 +156,16 @@ export function useSelectedThreadGitActions() { ); const refreshSelectedThreadBranches = useCallback(async (): Promise> => { - if (!selectedThread || !selectedThreadProject || !selectedThreadGitRootCwd) { - return []; - } - - const client = getEnvironmentClient(selectedThread.environmentId); - if (!client) { - return []; - } - - try { - const result = await vcsRefManager.load( - { environmentId: selectedThread.environmentId, cwd: selectedThreadGitRootCwd, query: null }, - client.vcs, - { limit: 100 }, - ); - return dedupeRemoteBranchesWithLocalMatches(result?.refs ?? []).filter( - (branch) => !branch.isRemote, - ); - } catch (error) { - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to load branches.", - ); - return []; - } - }, [selectedThread, selectedThreadGitRootCwd, selectedThreadProject]); + branchState.refresh(); + return dedupeRemoteBranchesWithLocalMatches(branchState.data?.refs ?? []).filter( + (branch) => !branch.isRemote, + ); + }, [branchState]); const syncSelectedThreadBranchState = useCallback( async (input: { - readonly thread: EnvironmentScopedThreadShell; + readonly thread: EnvironmentThreadShell; readonly cwd: string; - readonly branchRootCwd?: string | null; readonly nextThreadState?: { readonly branch?: string | null; readonly worktreePath?: string | null; @@ -166,104 +174,109 @@ export function useSelectedThreadGitActions() { if (input.nextThreadState) { await updateThreadGitContext(input.thread, input.nextThreadState); } - - const branchRootCwd = input.branchRootCwd ?? selectedThreadProject?.workspaceRoot ?? null; - if (branchRootCwd) { - vcsRefManager.invalidate({ - environmentId: input.thread.environmentId, - cwd: branchRootCwd, - query: null, - }); - await refreshSelectedThreadBranches(); - } - + branchState.refresh(); await refreshSelectedThreadGitStatus({ quiet: true, cwd: input.cwd }); }, - [ - refreshSelectedThreadBranches, - refreshSelectedThreadGitStatus, - selectedThreadProject?.workspaceRoot, - updateThreadGitContext, - ], + [branchState, refreshSelectedThreadGitStatus, updateThreadGitContext], ); const onCheckoutSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.switchRef( - { environmentId: thread.environmentId, cwd }, - { refName: branch }, - ); - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result?.refName ?? thread.branch, - worktreePath: selectedThreadWorktreePath, - }, - }); - }); + await runSelectedThreadGitMutation( + "switch_ref", + "Switching branch", + async ({ thread, cwd }) => { + const result = await switchRef({ + environmentId: thread.environmentId, + input: { cwd, refName: branch }, + }); + await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + }, + ); }, - [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + [ + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + switchRef, + ], ); const onCreateSelectedThreadBranch = useCallback( async (branch: string) => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.createRef( - { environmentId: thread.environmentId, cwd }, - { - refName: branch, - switchRef: true, - }, - ); - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result?.refName ?? thread.branch, - worktreePath: selectedThreadWorktreePath, - }, - }); - }); + await runSelectedThreadGitMutation( + "create_ref", + "Creating branch", + async ({ thread, cwd }) => { + const result = await createRef({ + environmentId: thread.environmentId, + input: { cwd, refName: branch, switchRef: true }, + }); + await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.refName ?? thread.branch, + worktreePath: selectedThreadWorktreePath, + }, + }); + }, + ); }, - [runSelectedThreadGitMutation, selectedThreadWorktreePath, syncSelectedThreadBranchState], + [ + runSelectedThreadGitMutation, + selectedThreadWorktreePath, + syncSelectedThreadBranchState, + createRef, + ], ); const onCreateSelectedThreadWorktree = useCallback( async (nextWorktree: { readonly baseBranch: string; readonly newBranch: string }) => { - await runSelectedThreadGitMutation(async ({ thread, project }) => { - const result = await vcsActionManager.createWorktree( - { environmentId: thread.environmentId, cwd: project.workspaceRoot }, - { - refName: nextWorktree.baseBranch, - newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), - path: null, - }, - ); - if (!result) { - return; - } - - await syncSelectedThreadBranchState({ - thread, - cwd: result.worktree.path, - branchRootCwd: project.workspaceRoot, - nextThreadState: { - branch: result.worktree.refName, - worktreePath: result.worktree.path, - }, - }); - }); + await runSelectedThreadGitMutation( + "create_worktree", + "Creating worktree", + async ({ thread, project }) => { + const result = await createWorktree({ + environmentId: thread.environmentId, + input: { + cwd: project.workspaceRoot, + refName: nextWorktree.baseBranch, + newRefName: sanitizeFeatureBranchName(nextWorktree.newBranch), + path: null, + }, + }); + await syncSelectedThreadBranchState({ + thread, + cwd: result.worktree.path, + nextThreadState: { + branch: result.worktree.refName, + worktreePath: result.worktree.path, + }, + }); + }, + ); }, - [runSelectedThreadGitMutation, syncSelectedThreadBranchState], + [createWorktree, runSelectedThreadGitMutation, syncSelectedThreadBranchState], ); const onPullSelectedThreadBranch = useCallback(async () => { - await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.pull({ environmentId: thread.environmentId, cwd }); - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - if (result) { + await runSelectedThreadGitMutation( + "pull", + "Pulling latest changes", + async ({ thread, cwd }) => { + const result = await pull({ + environmentId: thread.environmentId, + input: { cwd }, + }); + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); showGitActionResult({ type: "success", title: @@ -271,57 +284,60 @@ export function useSelectedThreadGitActions() { ? "Already up to date" : `Pulled latest on ${result.refName}`, }); - } - }); - }, [refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); + }, + ); + }, [pull, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation]); const onRunSelectedThreadGitAction = useCallback( async (input: GitActionRequestInput): Promise => { - return await runSelectedThreadGitMutation(async ({ thread, cwd }) => { - const result = await vcsActionManager.runChangeRequest( - { environmentId: thread.environmentId, cwd }, - { - actionId: uuidv4(), - action: input.action, - ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), - ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), - ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), - }, - { - gitStatus: vcsStatusManager.getSnapshot({ - environmentId: thread.environmentId, + return await runSelectedThreadGitMutation( + "run_change_request", + "Running source control action", + async ({ thread, cwd }) => { + const event = await runStackedAction({ + environmentId: thread.environmentId, + input: { cwd, - }).data, - }, - ); - if (!result) { - return null; - } - - showGitActionResult({ - type: "success", - title: result.toast.title, - description: result.toast.description, - prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, - }); - - if (result.branch.status === "created" && result.branch.name) { - await syncSelectedThreadBranchState({ - thread, - cwd, - nextThreadState: { - branch: result.branch.name, - worktreePath: selectedThreadWorktreePath, + actionId: uuidv4(), + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), + ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), }, }); - return result; - } + if (event.kind === "action_failed") { + throw new Error(event.message); + } + if (event.kind !== "action_finished") { + throw new Error("Source control action ended without a result."); + } - await refreshSelectedThreadGitStatus({ quiet: true, cwd }); - return result; - }); + const result = event.result; + showGitActionResult({ + type: "success", + title: result.toast.title, + description: result.toast.description, + prUrl: result.toast.cta.kind === "open_pr" ? result.toast.cta.url : undefined, + }); + + if (result.branch.status === "created" && result.branch.name) { + await syncSelectedThreadBranchState({ + thread, + cwd, + nextThreadState: { + branch: result.branch.name, + worktreePath: selectedThreadWorktreePath, + }, + }); + } else { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + } + return result; + }, + ); }, [ + runStackedAction, refreshSelectedThreadGitStatus, runSelectedThreadGitMutation, selectedThreadWorktreePath, diff --git a/apps/mobile/src/state/use-selected-thread-git-state.ts b/apps/mobile/src/state/use-selected-thread-git-state.ts index 6c855a3ebf7..a8c037db6f7 100644 --- a/apps/mobile/src/state/use-selected-thread-git-state.ts +++ b/apps/mobile/src/state/use-selected-thread-git-state.ts @@ -2,9 +2,10 @@ import { useMemo } from "react"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { useBranches } from "./queries"; +import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; import { useVcsActionState } from "./use-vcs-action-state"; -import { useVcsRefs } from "./use-vcs-refs"; -import { useSourceControlDiscovery } from "./use-source-control-discovery"; import { useThreadSelection } from "./use-thread-selection"; import { useSelectedThreadWorktree } from "./use-selected-thread-worktree"; @@ -20,7 +21,14 @@ export function useSelectedThreadGitState() { [selectedThread?.environmentId, selectedThreadCwd], ); const gitActionState = useVcsActionState(selectedThreadGitTarget); - const sourceControlDiscovery = useSourceControlDiscovery(selectedThread?.environmentId ?? null); + const sourceControlDiscovery = useEnvironmentQuery( + selectedThread === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: selectedThread.environmentId, + input: {}, + }), + ); const selectedThreadBranchTarget = useMemo( () => ({ @@ -30,7 +38,7 @@ export function useSelectedThreadGitState() { }), [selectedThread?.environmentId, selectedThreadProject?.workspaceRoot], ); - const selectedThreadBranchState = useVcsRefs(selectedThreadBranchTarget); + const selectedThreadBranchState = useBranches(selectedThreadBranchTarget); const selectedThreadBranches = useMemo( () => dedupeRemoteBranchesWithLocalMatches(selectedThreadBranchState.data?.refs ?? []).filter( diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 232135b6a7e..51c0fd35515 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,9 +1,10 @@ -import { useAtomValue } from "@effect/atom-react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, CommandId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; +import { threadEnvironment } from "../state/threads"; import { scopedRequestKey } from "../lib/scopedEntities"; import { buildPendingUserInputAnswers, @@ -12,9 +13,7 @@ import { setPendingUserInputCustomAnswer, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; -import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; import { useSelectedThreadDetail } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; @@ -54,6 +53,8 @@ function setUserInputDraftCustomAnswer( } export function useSelectedThreadRequests() { + const respondToApproval = useAtomSet(threadEnvironment.respondToApproval, { mode: "promise" }); + const respondToUserInput = useAtomSet(threadEnvironment.respondToUserInput, { mode: "promise" }); const { selectedThread: selectedThreadShell } = useThreadSelection(); const selectedThread = useSelectedThreadDetail(); const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); @@ -112,26 +113,21 @@ export function useSelectedThreadRequests() { return; } - const client = getEnvironmentClient(selectedThreadShell.environmentId); - if (!client) { - return; - } - setRespondingApprovalId(requestId); try { - await client.orchestration.dispatchCommand({ - type: "thread.approval.respond", - commandId: CommandId.make(uuidv4()), - threadId: selectedThreadShell.id, - requestId, - decision, - createdAt: new Date().toISOString(), + await respondToApproval({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + requestId, + decision, + }, }); } finally { setRespondingApprovalId((current) => (current === requestId ? null : current)); } }, - [selectedThreadShell], + [respondToApproval, selectedThreadShell], ); const onSubmitUserInput = useCallback(async () => { @@ -139,27 +135,27 @@ export function useSelectedThreadRequests() { return; } - const client = getEnvironmentClient(selectedThreadShell.environmentId); - if (!client) { - return; - } - setRespondingUserInputId(activePendingUserInput.requestId); try { - await client.orchestration.dispatchCommand({ - type: "thread.user-input.respond", - commandId: CommandId.make(uuidv4()), - threadId: selectedThreadShell.id, - requestId: activePendingUserInput.requestId, - answers: activePendingUserInputAnswers, - createdAt: new Date().toISOString(), + await respondToUserInput({ + environmentId: selectedThreadShell.environmentId, + input: { + threadId: selectedThreadShell.id, + requestId: activePendingUserInput.requestId, + answers: activePendingUserInputAnswers, + }, }); } finally { setRespondingUserInputId((current) => current === activePendingUserInput.requestId ? null : current, ); } - }, [activePendingUserInput, activePendingUserInputAnswers, selectedThreadShell]); + }, [ + activePendingUserInput, + activePendingUserInputAnswers, + respondToUserInput, + selectedThreadShell, + ]); return { activePendingApproval, diff --git a/apps/mobile/src/state/use-shell-snapshot.ts b/apps/mobile/src/state/use-shell-snapshot.ts deleted file mode 100644 index 56d69db7bfb..00000000000 --- a/apps/mobile/src/state/use-shell-snapshot.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; -import { useAtomValue } from "@effect/atom-react"; -import { Atom } from "effect/unstable/reactivity"; -import { - EMPTY_SHELL_SNAPSHOT_ATOM, - EMPTY_SHELL_SNAPSHOT_STATE, - createShellSnapshotManager, - getShellSnapshotTargetKey, - shellSnapshotStateAtom, - type ShellSnapshotState, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import type { CachedShellSnapshot } from "../lib/storage"; - -const cachedShellSnapshotMetadataAtom = Atom.make< - Readonly> ->({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:cached-shell-snapshot-metadata")); - -export const shellSnapshotManager = createShellSnapshotManager({ - getRegistry: () => appAtomRegistry, -}); - -export function hydrateCachedShellSnapshot(cached: CachedShellSnapshot): void { - shellSnapshotManager.syncSnapshot({ environmentId: cached.environmentId }, cached.snapshot); - appAtomRegistry.set(cachedShellSnapshotMetadataAtom, { - ...appAtomRegistry.get(cachedShellSnapshotMetadataAtom), - [cached.environmentId]: { - snapshotReceivedAt: cached.snapshotReceivedAt, - }, - }); -} - -export function markShellSnapshotLive(environmentId: EnvironmentId): void { - const current = appAtomRegistry.get(cachedShellSnapshotMetadataAtom); - if (current[environmentId] === undefined) { - return; - } - - const next = { ...current }; - delete next[environmentId]; - appAtomRegistry.set(cachedShellSnapshotMetadataAtom, next); -} - -export function clearCachedShellSnapshotMetadata(environmentId: EnvironmentId): void { - markShellSnapshotLive(environmentId); -} - -export function useCachedShellSnapshotMetadata(): Readonly< - Record -> { - return useAtomValue(cachedShellSnapshotMetadataAtom); -} - -export function useShellSnapshot(environmentId: EnvironmentId | null): ShellSnapshotState { - const targetKey = getShellSnapshotTargetKey({ environmentId }); - const state = useAtomValue( - targetKey !== null ? shellSnapshotStateAtom(targetKey) : EMPTY_SHELL_SNAPSHOT_ATOM, - ); - return targetKey === null ? EMPTY_SHELL_SNAPSHOT_STATE : state; -} - -export function useShellSnapshotStates( - environmentIds: ReadonlyArray, -): Readonly> { - const stableEnvironmentIds = useMemo( - () => Arr.sort(new Set(environmentIds), Order.String), - [environmentIds], - ); - const snapshotCacheRef = useRef>>({}); - - const subscribe = useCallback( - (onStoreChange: () => void) => { - const unsubs = stableEnvironmentIds.map((environmentId) => - appAtomRegistry.subscribe(shellSnapshotStateAtom(environmentId), onStoreChange), - ); - return () => { - for (const unsub of unsubs) { - unsub(); - } - }; - }, - [stableEnvironmentIds], - ); - - const getSnapshot = useCallback(() => { - const previous = snapshotCacheRef.current; - let hasChanged = Object.keys(previous).length !== stableEnvironmentIds.length; - const next: Record = {}; - - for (const environmentId of stableEnvironmentIds) { - const snapshot = shellSnapshotManager.getSnapshot({ environmentId }); - next[environmentId] = snapshot; - if (!hasChanged && previous[environmentId] !== snapshot) { - hasChanged = true; - } - } - - if (!hasChanged) { - return previous; - } - - snapshotCacheRef.current = next; - return next; - }, [stableEnvironmentIds]); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} diff --git a/apps/mobile/src/state/use-source-control-discovery.ts b/apps/mobile/src/state/use-source-control-discovery.ts deleted file mode 100644 index 8f206be2cee..00000000000 --- a/apps/mobile/src/state/use-source-control-discovery.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, - type SourceControlDiscoveryClient, - type SourceControlDiscoveryState, - type SourceControlDiscoveryTarget, - createSourceControlDiscoveryManager, - getSourceControlDiscoveryTargetKey, - sourceControlDiscoveryStateAtom, -} from "@t3tools/client-runtime"; -import type { EnvironmentId, SourceControlDiscoveryResult } from "@t3tools/contracts"; -import { useEffect, useMemo } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => getEnvironmentClient(environmentId)?.server ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -function sourceControlDiscoveryTargetForEnvironment( - environmentId: EnvironmentId | null, -): SourceControlDiscoveryTarget { - return { key: environmentId ?? null }; -} - -export function refreshSourceControlDiscoveryForEnvironment( - environmentId: EnvironmentId | null, - client?: SourceControlDiscoveryClient | null, -): Promise { - return sourceControlDiscoveryManager.refresh( - sourceControlDiscoveryTargetForEnvironment(environmentId), - client ?? undefined, - ); -} - -export function invalidateSourceControlDiscoveryForEnvironment( - environmentId: EnvironmentId | null, -): void { - sourceControlDiscoveryManager.invalidate( - sourceControlDiscoveryTargetForEnvironment(environmentId), - ); -} - -export function resetSourceControlDiscoveryState(): void { - sourceControlDiscoveryManager.reset(); -} - -export function resetSourceControlDiscoveryStateForTests(): void { - resetSourceControlDiscoveryState(); -} - -export function useSourceControlDiscovery( - environmentId: EnvironmentId | null, -): SourceControlDiscoveryState { - const target = useMemo( - () => sourceControlDiscoveryTargetForEnvironment(environmentId), - [environmentId], - ); - - useEffect(() => { - return sourceControlDiscoveryManager.watch(target); - }, [target]); - - const targetKey = getSourceControlDiscoveryTargetKey(target); - const state = useAtomValue( - targetKey !== null - ? sourceControlDiscoveryStateAtom(targetKey) - : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, - ); - return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; -} diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 9ea13eef3e3..328557a2005 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -1,84 +1,82 @@ -import { useAtomValue } from "@effect/atom-react"; import { - createTerminalSessionManager, - EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - EMPTY_TERMINAL_SESSION_ATOM, - getKnownTerminalSessionTarget, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - terminalSessionStateAtom, - type TerminalSessionTarget, + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + EMPTY_TERMINAL_SESSION_STATE, + type KnownTerminalSession, type TerminalSessionState, -} from "@t3tools/client-runtime"; -import type { - EnvironmentId, - TerminalAttachInput, - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, -} from "@t3tools/contracts"; +} from "@t3tools/client-runtime/state/terminal"; +import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; import { useMemo } from "react"; -import { appAtomRegistry } from "./atom-registry"; +import { useEnvironmentQuery } from "./query"; +import { terminalEnvironment } from "./terminal"; -export const terminalSessionManager = createTerminalSessionManager({ - getRegistry: () => appAtomRegistry, -}); - -export function subscribeTerminalMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: { - readonly terminal: { - readonly onMetadata: ( - listener: (event: TerminalMetadataStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; - }; -}) { - return terminalSessionManager.subscribeMetadata(input); -} - -export function attachTerminalSession(input: { - readonly environmentId: EnvironmentId; - readonly client: Parameters[0]["client"]; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: (event: TerminalAttachStreamEvent) => void; -}) { - return terminalSessionManager.attach({ - environmentId: input.environmentId, - client: input.client, - terminal: input.terminal, - ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), - ...(input.onEvent ? { onEvent: input.onEvent } : {}), - }); -} - -export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { - const target = getKnownTerminalSessionTarget(input); - return useAtomValue( - target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, +export function useAttachedTerminalSession(input: { + readonly environmentId: EnvironmentId | null; + readonly terminal: TerminalAttachInput | null; +}): TerminalSessionState { + const attach = useEnvironmentQuery( + input.environmentId !== null && input.terminal !== null + ? terminalEnvironment.attach({ + environmentId: input.environmentId, + input: input.terminal, + }) + : null, ); -} - -export function useTerminalSessionTarget(input: TerminalSessionTarget) { - return useMemo( - () => ({ - environmentId: input.environmentId, - threadId: input.threadId, - terminalId: input.terminalId, - }), - [input.environmentId, input.threadId, input.terminalId], + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), ); + + return useMemo(() => { + if (input.environmentId === null || input.terminal === null) { + return EMPTY_TERMINAL_SESSION_STATE; + } + const summary = + metadata.data?.find( + (terminal) => + terminal.threadId === input.terminal?.threadId && + terminal.terminalId === input.terminal?.terminalId, + ) ?? null; + const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; + }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); } export function useKnownTerminalSessions(input: { - readonly environmentId: TerminalSessionTarget["environmentId"]; - readonly threadId: TerminalSessionTarget["threadId"]; -}) { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray { + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), ); + return useMemo(() => { + if (input.environmentId === null) { + return []; + } + return (metadata.data ?? []) + .filter((summary) => input.threadId === null || summary.threadId === input.threadId) + .map((summary) => ({ + target: { + environmentId: input.environmentId!, + threadId: ThreadId.make(summary.threadId), + terminalId: summary.terminalId, + }, + state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), + })) + .sort((left, right) => + left.target.terminalId.localeCompare(right.target.terminalId, undefined, { + numeric: true, + }), + ); + }, [input.environmentId, input.threadId, metadata.data]); } diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 7dfdc4cd57e..a3200a3840c 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -1,10 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; -import { Atom } from "effect/unstable/reactivity"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -14,7 +12,7 @@ import { } from "../lib/composerImages"; import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -import { buildThreadFeed, type QueuedThreadMessage } from "../lib/threadActivity"; +import { buildThreadFeed } from "../lib/threadActivity"; import { appAtomRegistry } from "../state/atom-registry"; import { appendComposerDraftAttachments, @@ -26,24 +24,11 @@ import { setComposerDraftText, useComposerDraft, } from "./use-composer-drafts"; -import { getEnvironmentClient } from "./environment-session-registry"; -import type { ConnectedEnvironmentSummary } from "../state/remote-runtime-types"; -import { - setPendingConnectionError, - useRemoteConnectionStatus, -} from "../state/use-remote-environment-registry"; -import { useRemoteCatalog } from "../state/use-remote-catalog"; +import { setPendingConnectionError } from "../state/use-remote-environment-registry"; import { useSelectedThreadDetail } from "../state/use-thread-detail"; import { useThreadSelection } from "../state/use-thread-selection"; - -const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:thread-composer:dispatching-message-id"), -); - -const queuedMessagesByThreadKeyAtom = Atom.make>>( - {}, -).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-composer:queued-messages")); +import { enqueueThreadOutboxMessage, useThreadOutboxMessages } from "./thread-outbox"; +import { dispatchingQueuedMessageIdAtom } from "./use-thread-outbox-drain"; export function appendReviewCommentToDraft(input: { readonly environmentId: EnvironmentId; @@ -76,112 +61,12 @@ export function useThreadDraftForThread(input: { }; } -function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { - appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); -} - -function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { - const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); - appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); -} - -function enqueueQueuedMessage(message: QueuedThreadMessage): void { - const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); - const threadKey = scopedThreadKey(message.environmentId, message.threadId); - appAtomRegistry.set(queuedMessagesByThreadKeyAtom, { - ...current, - [threadKey]: [...(current[threadKey] ?? []), message], - }); -} - -function removeQueuedMessage( - environmentId: EnvironmentId, - threadId: ThreadId, - queuedMessageId: MessageId, -): void { - const current = appAtomRegistry.get(queuedMessagesByThreadKeyAtom); - const threadKey = scopedThreadKey(environmentId, threadId); - const existing = current[threadKey]; - if (!existing) { - return; - } - - const nextQueue = existing.filter((entry) => entry.messageId !== queuedMessageId); - const next = { ...current }; - if (nextQueue.length === 0) { - delete next[threadKey]; - } else { - next[threadKey] = nextQueue; - } - - appAtomRegistry.set(queuedMessagesByThreadKeyAtom, next); -} - -function useQueueDrain(input: { - readonly dispatchingQueuedMessageId: MessageId | null; - readonly queuedMessagesByThreadKey: Record>; - readonly threads: ReadonlyArray; - readonly environments: ReadonlyArray; - readonly sendQueuedMessage: (message: QueuedThreadMessage) => Promise; -}) { - const { - dispatchingQueuedMessageId, - environments, - queuedMessagesByThreadKey, - sendQueuedMessage, - threads, - } = input; - - useEffect(() => { - if (dispatchingQueuedMessageId !== null) { - return; - } - - for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { - const nextQueuedMessage = queuedMessages[0]; - if (!nextQueuedMessage) { - continue; - } - - const thread = threads.find( - (candidate) => scopedThreadKey(candidate.environmentId, candidate.id) === threadKey, - ); - if (!thread) { - continue; - } - - const environment = environments.find( - (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, - ); - if (!environment || environment.connectionState !== "ready") { - continue; - } - - const threadStatus = thread.session?.status; - if (threadStatus === "running" || threadStatus === "starting") { - continue; - } - - void sendQueuedMessage(nextQueuedMessage); - return; - } - }, [ - dispatchingQueuedMessageId, - environments, - queuedMessagesByThreadKey, - sendQueuedMessage, - threads, - ]); -} - export function useThreadComposerState() { - const { connectedEnvironments } = useRemoteConnectionStatus(); - const { threads } = useRemoteCatalog(); const { selectedThread: selectedThreadShell } = useThreadSelection(); - const selectedThread = useSelectedThreadDetail(); + const selectedThreadDetail = useSelectedThreadDetail(); const composerDrafts = useAtomValue(composerDraftsAtom); const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); - const queuedMessagesByThreadKey = useAtomValue(queuedMessagesByThreadKeyAtom); + const queuedMessagesByThreadKey = useThreadOutboxMessages(); useEffect(() => { ensureComposerDraftsLoaded(); @@ -197,10 +82,14 @@ export function useThreadComposerState() { const selectedThreadFeed = useMemo( () => - selectedThread - ? buildThreadFeed(selectedThread, selectedThreadQueuedMessages, dispatchingQueuedMessageId) + selectedThreadDetail + ? buildThreadFeed( + selectedThreadDetail, + selectedThreadQueuedMessages, + dispatchingQueuedMessageId, + ) : [], - [dispatchingQueuedMessageId, selectedThread, selectedThreadQueuedMessages], + [dispatchingQueuedMessageId, selectedThreadDetail, selectedThreadQueuedMessages], ); const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; @@ -209,6 +98,7 @@ export function useThreadComposerState() { const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThreadSessionActivity = useMemo(() => { + const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread?.session) { return null; } @@ -217,10 +107,11 @@ export function useThreadComposerState() { orchestrationStatus: selectedThread.session.status, activeTurnId: selectedThread.session.activeTurnId ?? undefined, }; - }, [selectedThread]); + }, [selectedThreadDetail, selectedThreadShell]); const queuedSendStartedAt = selectedThreadQueuedMessages[0]?.createdAt ?? null; const activeWorkStartedAt = useMemo(() => { + const selectedThread = selectedThreadDetail ?? selectedThreadShell; if (!selectedThread) { return null; } @@ -230,71 +121,19 @@ export function useThreadComposerState() { selectedThreadSessionActivity, queuedSendStartedAt, ); - }, [queuedSendStartedAt, selectedThread, selectedThreadSessionActivity]); + }, [ + queuedSendStartedAt, + selectedThreadDetail, + selectedThreadSessionActivity, + selectedThreadShell, + ]); + const selectedThread = selectedThreadDetail ?? selectedThreadShell; const activeThreadBusy = !!selectedThread && (selectedThread.session?.status === "running" || selectedThread.session?.status === "starting"); - const sendQueuedMessage = useCallback( - async (queuedMessage: QueuedThreadMessage) => { - const client = getEnvironmentClient(queuedMessage.environmentId); - const thread = threads.find( - (candidate) => - candidate.environmentId === queuedMessage.environmentId && - candidate.id === queuedMessage.threadId, - ); - if (!client || !thread) { - return; - } - - beginDispatchingQueuedMessage(queuedMessage.messageId); - try { - await client.orchestration.dispatchCommand({ - type: "thread.turn.start", - commandId: queuedMessage.commandId, - threadId: queuedMessage.threadId, - message: { - messageId: queuedMessage.messageId, - role: "user", - text: queuedMessage.text, - attachments: queuedMessage.attachments, - }, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - createdAt: queuedMessage.createdAt, - }); - - removeQueuedMessage( - queuedMessage.environmentId, - queuedMessage.threadId, - queuedMessage.messageId, - ); - } catch (error) { - removeQueuedMessage( - queuedMessage.environmentId, - queuedMessage.threadId, - queuedMessage.messageId, - ); - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to send message.", - ); - } finally { - finishDispatchingQueuedMessage(queuedMessage.messageId); - } - }, - [threads], - ); - - useQueueDrain({ - dispatchingQueuedMessageId, - queuedMessagesByThreadKey, - threads, - environments: connectedEnvironments, - sendQueuedMessage, - }); - - const onSendMessage = useCallback(() => { + const onSendMessage = useCallback(async () => { if (!selectedThreadShell) { return; } @@ -308,16 +147,22 @@ export function useThreadComposerState() { } const metadata = makeQueuedMessageMetadata(); - enqueueQueuedMessage({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - messageId: MessageId.make(metadata.messageId), - commandId: CommandId.make(metadata.commandId), - text, - attachments, - createdAt: metadata.createdAt, - }); - clearComposerDraft(threadKey); + try { + await enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId: MessageId.make(metadata.messageId), + commandId: CommandId.make(metadata.commandId), + text, + attachments, + createdAt: metadata.createdAt, + }); + clearComposerDraft(threadKey); + } catch (error) { + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to save the queued message.", + ); + } }, [composerDrafts, selectedThreadShell]); const onChangeDraftMessage = useCallback( diff --git a/apps/mobile/src/state/use-thread-detail.ts b/apps/mobile/src/state/use-thread-detail.ts index 900dbd648b5..388b4d9afcb 100644 --- a/apps/mobile/src/state/use-thread-detail.ts +++ b/apps/mobile/src/state/use-thread-detail.ts @@ -1,82 +1,26 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_THREAD_DETAIL_ATOM, - EMPTY_THREAD_DETAIL_STATE, - createThreadDetailManager, - getThreadDetailTargetKey, - threadDetailStateAtom, - type ThreadDetailState, - type ThreadDetailTarget, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; -import { derivePendingApprovals, derivePendingUserInputs } from "../lib/threadActivity"; -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; +import { useEnvironmentThread } from "./threads"; import { useThreadSelection } from "./use-thread-selection"; -function shouldKeepThreadDetailWarm(state: ThreadDetailState): boolean { - const thread = state.data; - if (!thread || state.isDeleted) { - return false; - } - - if (thread.latestTurn?.sourceProposedPlan) { - return true; - } - - const sessionStatus = thread.session?.status; - if (sessionStatus && sessionStatus !== "idle" && sessionStatus !== "stopped") { - return true; - } - - return ( - derivePendingApprovals(thread.activities).length > 0 || - derivePendingUserInputs(thread.activities).length > 0 - ); +export interface ThreadDetailTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; } -const threadDetailManager = createThreadDetailManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.orchestration : null; - }, - getClientIdentity: (environmentId) => { - return getEnvironmentClient(environmentId) ? environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - retention: { - idleTtlMs: 5 * 60 * 1_000, - maxRetainedEntries: 24, - shouldKeepWarm: (_target, state) => shouldKeepThreadDetailWarm(state), - }, -}); - -export function useThreadDetail(target: ThreadDetailTarget): ThreadDetailState { - const { environmentId, threadId } = target; - const targetKey = getThreadDetailTargetKey(target); - - useEffect( - () => threadDetailManager.watch({ environmentId, threadId }), - [environmentId, threadId], - ); - - const state = useAtomValue( - targetKey !== null ? threadDetailStateAtom(targetKey) : EMPTY_THREAD_DETAIL_ATOM, - ); - return targetKey === null ? EMPTY_THREAD_DETAIL_STATE : state; +export function useThreadDetail(target: ThreadDetailTarget) { + return useEnvironmentThread(target.environmentId, target.threadId); } -export function useSelectedThreadDetail() { +export function useSelectedThreadDetailState() { const { selectedThread } = useThreadSelection(); - const state = useThreadDetail({ + return useThreadDetail({ environmentId: selectedThread?.environmentId ?? null, threadId: selectedThread?.id ?? null, }); +} - return useMemo(() => state.data, [state.data]); +export function useSelectedThreadDetail() { + return Option.getOrNull(useSelectedThreadDetailState().data); } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts new file mode 100644 index 00000000000..16e82602668 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -0,0 +1,175 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { type MessageId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { scopedThreadKey } from "../lib/scopedEntities"; +import { appAtomRegistry } from "./atom-registry"; +import { useThreadShells } from "./entities"; +import { + ensureThreadOutboxLoaded, + removeThreadOutboxMessage, + threadOutboxRetryDelayMs, + type QueuedThreadMessage, + useThreadOutboxMessages, +} from "./thread-outbox"; +import { threadEnvironment } from "./threads"; +import { useRemoteConnectionStatus } from "./use-remote-environment-registry"; + +export const dispatchingQueuedMessageIdAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:thread-outbox:dispatching-message-id"), +); + +function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { + appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); +} + +function finishDispatchingQueuedMessage(queuedMessageId: MessageId): void { + const current = appAtomRegistry.get(dispatchingQueuedMessageIdAtom); + appAtomRegistry.set(dispatchingQueuedMessageIdAtom, current === queuedMessageId ? null : current); +} + +function findThread( + threads: ReadonlyArray, + message: QueuedThreadMessage, +): EnvironmentThreadShell | undefined { + return threads.find( + (candidate) => + candidate.environmentId === message.environmentId && candidate.id === message.threadId, + ); +} + +export function useThreadOutboxDrain(): void { + const startTurn = useAtomSet(threadEnvironment.startTurn, { mode: "promise" }); + const dispatchingQueuedMessageId = useAtomValue(dispatchingQueuedMessageIdAtom); + const queuedMessagesByThreadKey = useThreadOutboxMessages(); + const threads = useThreadShells(); + const { connectedEnvironments } = useRemoteConnectionStatus(); + const [retryTick, setRetryTick] = useState(0); + const retryAttemptRef = useRef(new Map()); + const retryNotBeforeRef = useRef(new Map()); + const retryTimersRef = useRef(new Map>()); + + useEffect(() => { + ensureThreadOutboxLoaded(); + return () => { + for (const timer of retryTimersRef.current.values()) { + clearTimeout(timer); + } + retryTimersRef.current.clear(); + }; + }, []); + + const sendQueuedMessage = useCallback( + async (queuedMessage: QueuedThreadMessage) => { + const thread = findThread(threads, queuedMessage); + if (!thread) { + return false; + } + + try { + await startTurn({ + environmentId: queuedMessage.environmentId, + input: { + commandId: queuedMessage.commandId, + threadId: queuedMessage.threadId, + message: { + messageId: queuedMessage.messageId, + role: "user", + text: queuedMessage.text, + attachments: queuedMessage.attachments, + }, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + createdAt: queuedMessage.createdAt, + }, + }); + await removeThreadOutboxMessage(queuedMessage); + return true; + } catch (error) { + console.warn("[thread-outbox] queued message delivery failed", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + error, + }); + return false; + } + }, + [startTurn, threads], + ); + + useEffect(() => { + if (dispatchingQueuedMessageId !== null) { + return; + } + + for (const [threadKey, queuedMessages] of Object.entries(queuedMessagesByThreadKey)) { + const nextQueuedMessage = queuedMessages[0]; + if (!nextQueuedMessage) { + continue; + } + if ((retryNotBeforeRef.current.get(nextQueuedMessage.messageId) ?? 0) > Date.now()) { + continue; + } + + const thread = findThread(threads, nextQueuedMessage); + if (!thread || scopedThreadKey(thread.environmentId, thread.id) !== threadKey) { + continue; + } + + const environment = connectedEnvironments.find( + (candidate) => candidate.environmentId === nextQueuedMessage.environmentId, + ); + if (!environment || environment.connectionState !== "connected") { + continue; + } + + if (thread.session?.status === "running" || thread.session?.status === "starting") { + continue; + } + + beginDispatchingQueuedMessage(nextQueuedMessage.messageId); + void sendQueuedMessage(nextQueuedMessage) + .then((sent) => { + if (sent) { + retryAttemptRef.current.delete(nextQueuedMessage.messageId); + retryNotBeforeRef.current.delete(nextQueuedMessage.messageId); + const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + retryTimersRef.current.delete(nextQueuedMessage.messageId); + } + return; + } + + const retryAttempt = (retryAttemptRef.current.get(nextQueuedMessage.messageId) ?? 0) + 1; + retryAttemptRef.current.set(nextQueuedMessage.messageId, retryAttempt); + const retryDelayMs = threadOutboxRetryDelayMs(retryAttempt); + retryNotBeforeRef.current.set(nextQueuedMessage.messageId, Date.now() + retryDelayMs); + const pendingTimer = retryTimersRef.current.get(nextQueuedMessage.messageId); + if (pendingTimer !== undefined) { + clearTimeout(pendingTimer); + } + const retryTimer = setTimeout(() => { + retryTimersRef.current.delete(nextQueuedMessage.messageId); + setRetryTick((current) => current + 1); + }, retryDelayMs); + retryTimersRef.current.set(nextQueuedMessage.messageId, retryTimer); + }) + .finally(() => { + finishDispatchingQueuedMessage(nextQueuedMessage.messageId); + }); + return; + } + }, [ + connectedEnvironments, + dispatchingQueuedMessageId, + queuedMessagesByThreadKey, + retryTick, + sendQueuedMessage, + threads, + ]); +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index c303faed617..06175b6d237 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -1,11 +1,12 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type ScopedProjectRef } from "@t3tools/contracts"; -import { EnvironmentScopedThreadShell } from "@t3tools/client-runtime"; -import { EnvironmentScopedProjectShell } from "@t3tools/client-runtime"; -import { useRemoteCatalog } from "./use-remote-catalog"; -import { useRemoteEnvironmentState } from "./use-remote-environment-registry"; +import { useProject, useThreadShell } from "../state/entities"; +import { + useRemoteEnvironmentRuntime, + useSavedRemoteConnection, +} from "./use-remote-environment-registry"; function firstRouteParam(value: string | string[] | undefined): string | null { if (Array.isArray(value)) { @@ -15,43 +16,7 @@ function firstRouteParam(value: string | string[] | undefined): string | null { return value ?? null; } -function deriveSelectedThread( - selectedThreadRef: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId } | null, - threads: ReadonlyArray, -): EnvironmentScopedThreadShell | null { - if (!selectedThreadRef) { - return null; - } - - return ( - threads.find( - (thread) => - thread.environmentId === selectedThreadRef.environmentId && - thread.id === selectedThreadRef.threadId, - ) ?? null - ); -} - -function deriveSelectedThreadProject( - selectedThread: EnvironmentScopedThreadShell | null, - projects: ReadonlyArray, -): EnvironmentScopedProjectShell | null { - if (!selectedThread) { - return null; - } - - return ( - projects.find( - (project) => - project.environmentId === selectedThread.environmentId && - project.id === selectedThread.projectId, - ) ?? null - ); -} - export function useThreadSelection() { - const { projects, threads } = useRemoteCatalog(); - const { environmentStateById, savedConnectionsById } = useRemoteEnvironmentState(); const params = useLocalSearchParams<{ environmentId?: string | string[]; threadId?: string | string[]; @@ -68,22 +33,21 @@ export function useThreadSelection() { threadId: ThreadId.make(threadId), }; }, [params.environmentId, params.threadId]); - const selectedThread = useMemo( - () => deriveSelectedThread(selectedThreadRef, threads), - [selectedThreadRef, threads], + const selectedThread = useThreadShell(selectedThreadRef); + const selectedProjectRef = useMemo( + () => + selectedThread === null + ? null + : { + environmentId: selectedThread.environmentId, + projectId: selectedThread.projectId, + }, + [selectedThread], ); - - const selectedThreadProject = useMemo( - () => deriveSelectedThreadProject(selectedThread, projects), - [projects, selectedThread], - ); - - const selectedEnvironmentConnection = selectedThread - ? (savedConnectionsById[selectedThread.environmentId] ?? null) - : null; - const selectedEnvironmentRuntime = selectedThread - ? (environmentStateById[selectedThread.environmentId] ?? null) - : null; + const selectedThreadProject = useProject(selectedProjectRef); + const selectedEnvironmentId = selectedThread?.environmentId ?? null; + const selectedEnvironmentConnection = useSavedRemoteConnection(selectedEnvironmentId); + const selectedEnvironmentRuntime = useRemoteEnvironmentRuntime(selectedEnvironmentId); return { selectedThreadRef, diff --git a/apps/mobile/src/state/use-vcs-action-state.ts b/apps/mobile/src/state/use-vcs-action-state.ts index 64e4da958ef..a63a0c085f1 100644 --- a/apps/mobile/src/state/use-vcs-action-state.ts +++ b/apps/mobile/src/state/use-vcs-action-state.ts @@ -1,40 +1,85 @@ import { useAtomValue } from "@effect/atom-react"; import { - type VcsActionState, - type VcsActionTarget, + applyVcsActionProgressEvent, EMPTY_VCS_ACTION_ATOM, EMPTY_VCS_ACTION_STATE, - createVcsActionManager, getVcsActionTargetKey, + type VcsActionState, + type VcsActionTarget, vcsActionStateAtom, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/vcs"; +import type { GitActionProgressEvent } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { uuidv4 } from "../lib/uuid"; import { appAtomRegistry } from "./atom-registry"; -import { getEnvironmentClient } from "./environment-session-registry"; +import { gitEnvironment } from "./git"; + +function setVcsActionState(target: VcsActionTarget, state: VcsActionState): void { + const targetKey = getVcsActionTargetKey(target); + if (targetKey !== null) { + appAtomRegistry.set(vcsActionStateAtom(targetKey), state); + } +} -export const vcsActionManager = createVcsActionManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; +export function beginVcsAction( + target: VcsActionTarget, + input: { + readonly operation: VcsActionState["operation"]; + readonly label: string; }, - getActionId: uuidv4, -}); +): void { + setVcsActionState(target, { + ...EMPTY_VCS_ACTION_STATE, + isRunning: true, + operation: input.operation, + currentLabel: input.label, + currentPhaseLabel: input.label, + phaseStartedAtMs: Date.now(), + }); +} + +export function completeVcsAction(target: VcsActionTarget): void { + setVcsActionState(target, EMPTY_VCS_ACTION_STATE); +} + +export function failVcsAction( + target: VcsActionTarget, + operation: VcsActionState["operation"], + error: unknown, +): void { + setVcsActionState(target, { + ...EMPTY_VCS_ACTION_STATE, + operation, + error: error instanceof Error ? error.message : "Source control action failed.", + }); +} export function useVcsActionState(target: VcsActionTarget): VcsActionState { const targetKey = getVcsActionTargetKey(target); + const runStackedActionState = useAtomValue(gitEnvironment.runStackedAction); const state = useAtomValue( targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, ); + + useEffect(() => { + const event = Option.getOrNull(AsyncResult.value(runStackedActionState)); + if (event === null || targetKey === null || event.cwd !== target.cwd) { + return; + } + appAtomRegistry.set( + vcsActionStateAtom(targetKey), + applyVcsActionProgressEvent( + appAtomRegistry.get(vcsActionStateAtom(targetKey)), + event as GitActionProgressEvent, + ), + ); + }, [runStackedActionState, target.cwd, targetKey]); + return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; } -// --------------------------------------------------------------------------- -// Git action result notification -// --------------------------------------------------------------------------- - export interface GitActionResultNotification { readonly type: "success" | "error"; readonly title: string; @@ -84,10 +129,6 @@ export function useGitActionResultNotification(): { return { result, dismiss: dismissGitActionResult }; } -// --------------------------------------------------------------------------- -// Unified git action progress (combines running state + result notification) -// --------------------------------------------------------------------------- - export type GitActionProgressPhase = "idle" | "running" | "success" | "error"; export interface GitActionProgress { diff --git a/apps/mobile/src/state/use-vcs-refs.ts b/apps/mobile/src/state/use-vcs-refs.ts deleted file mode 100644 index 3af3a6e945e..00000000000 --- a/apps/mobile/src/state/use-vcs-refs.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useMemo } from "react"; -import { - type VcsRefState, - type VcsRefTarget, - EMPTY_VCS_REF_ATOM, - EMPTY_VCS_REF_STATE, - createVcsRefManager, - getVcsRefTargetKey, - vcsRefStateAtom, -} from "@t3tools/client-runtime"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -const VCS_REF_LIST_LIMIT = 100; -const VCS_REF_STALE_TIME_MS = 5_000; - -export const vcsRefManager = createVcsRefManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.vcs : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - watchLimit: VCS_REF_LIST_LIMIT, - staleTimeMs: VCS_REF_STALE_TIME_MS, - onBackgroundError: (error) => { - console.warn("[vcs-refs] background refresh failed", error); - }, -}); - -export function useVcsRefs(target: VcsRefTarget): VcsRefState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: target.query ?? null, - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getVcsRefTargetKey(stableTarget); - - useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); - return targetKey === null ? EMPTY_VCS_REF_STATE : state; -} diff --git a/apps/mobile/src/state/use-vcs-status.ts b/apps/mobile/src/state/use-vcs-status.ts deleted file mode 100644 index e7d7049d332..00000000000 --- a/apps/mobile/src/state/use-vcs-status.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsStatusState, - type VcsStatusTarget, - EMPTY_VCS_STATUS_ATOM, - EMPTY_VCS_STATUS_STATE, - createVcsStatusManager, - getVcsStatusTargetKey, - vcsStatusStateAtom, -} from "@t3tools/client-runtime"; -import { useEffect } from "react"; - -import { appAtomRegistry } from "./atom-registry"; -import { - getEnvironmentClient, - subscribeEnvironmentConnections, -} from "./environment-session-registry"; - -/** - * Singleton VCS status manager for the mobile app. - * - * Uses ref-counted `onStatus` subscriptions (one per unique cwd) - * rather than one-shot `refreshStatus` RPCs. Multiple threads - * sharing the same cwd (i.e. same project, no worktree) share - * a single WS subscription. - * - * `subscribeClientChanges` ensures subscriptions are established - * even when the WS connection isn't ready at mount time, and - * re-established on reconnection. - */ -export const vcsStatusManager = createVcsStatusManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = getEnvironmentClient(environmentId); - return client ? client.vcs : null; - }, - getClientIdentity: (environmentId) => { - return getEnvironmentClient(environmentId) ? environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -/** - * Subscribe to live VCS status for a target (environmentId + cwd). - * - * Mirrors the web's `useVcsStatus` hook. Automatically subscribes - * on mount, ref-counts shared cwds, and unsubscribes on unmount. - * Returns reactive `VcsStatusState` via Effect atoms. - */ -export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - - useEffect( - () => vcsStatusManager.watch({ environmentId: target.environmentId, cwd: target.cwd }), - [target.environmentId, target.cwd], - ); - - const state = useAtomValue( - targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, - ); - return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; -} diff --git a/apps/mobile/src/state/vcs.ts b/apps/mobile/src/state/vcs.ts new file mode 100644 index 00000000000..af18fe0bd91 --- /dev/null +++ b/apps/mobile/src/state/vcs.ts @@ -0,0 +1,5 @@ +import { createVcsEnvironmentAtoms } from "@t3tools/client-runtime/state/vcs"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/mobile/src/state/workspace.ts b/apps/mobile/src/state/workspace.ts new file mode 100644 index 00000000000..368cd0bc468 --- /dev/null +++ b/apps/mobile/src/state/workspace.ts @@ -0,0 +1,30 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useMemo } from "react"; + +import { environmentShellSummaryAtom } from "./shell"; +import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; +import { useEnvironments } from "./environments"; + +export function useWorkspaceState() { + const { isReady, networkStatus, environments } = useEnvironments(); + const shellSummary = useAtomValue(environmentShellSummaryAtom); + const projectedEnvironments = useMemo( + () => environments.map(projectWorkspaceEnvironment), + [environments], + ); + const state = useMemo( + () => + projectWorkspaceState({ + isReady, + networkStatus, + environments: projectedEnvironments, + shellSummary, + }), + [isReady, networkStatus, projectedEnvironments, shellSummary], + ); + + return { + environments: projectedEnvironments, + state, + }; +} diff --git a/apps/mobile/src/state/workspaceModel.test.ts b/apps/mobile/src/state/workspaceModel.test.ts new file mode 100644 index 00000000000..e51273d57de --- /dev/null +++ b/apps/mobile/src/state/workspaceModel.test.ts @@ -0,0 +1,123 @@ +import type { EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; +import { + BearerConnectionProfile, + BearerConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { projectWorkspaceEnvironment, projectWorkspaceState } from "./workspaceModel"; +import type { EnvironmentPresentation } from "./environments"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +function environment( + phase: EnvironmentPresentation["connection"]["phase"], +): EnvironmentPresentation { + const connectionId = `bearer:${ENVIRONMENT_ID}`; + return { + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + displayUrl: "https://environment.example.test", + relayManaged: false, + entry: { + target: new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + connectionId, + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: ENVIRONMENT_ID, + label: "Julius's MacBook Pro", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }), + ), + }, + connection: { + phase, + error: phase === "error" ? "Connection failed." : null, + traceId: phase === "error" ? "trace-1" : null, + }, + serverConfig: null, + }; +} + +const EMPTY_SHELL_SUMMARY: EnvironmentShellSummary = { + hasSnapshot: false, + hasSynchronizingShell: false, + hasCachedShell: false, + hasLiveShell: false, + firstError: null, + latestSnapshotUpdatedAt: null, +}; + +const CACHED_SHELL_SUMMARY: EnvironmentShellSummary = { + ...EMPTY_SHELL_SUMMARY, + hasSnapshot: true, + hasSynchronizingShell: true, + hasCachedShell: true, + latestSnapshotUpdatedAt: "2026-06-07T00:00:00.000Z", +}; + +describe("mobile workspace projection", () => { + it("preserves explicit offline state without presenting it as a connection error", () => { + const projected = projectWorkspaceEnvironment(environment("offline")); + + expect(projected.connectionState).toBe("offline"); + expect(projected.connectionError).toBeNull(); + }); + + it("reports offline before stale connected presentations", () => { + const environments = [projectWorkspaceEnvironment(environment("connected"))]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "offline", + environments, + shellSummary: EMPTY_SHELL_SUMMARY, + }); + + expect(state.connectionState).toBe("offline"); + expect(state.networkStatus).toBe("offline"); + expect(state.hasReadyEnvironment).toBe(false); + }); + + it("projects reconnecting environments dynamically from active phases", () => { + const environments = [ + projectWorkspaceEnvironment(environment("reconnecting")), + projectWorkspaceEnvironment({ + ...environment("connected"), + environmentId: EnvironmentId.make("environment-2"), + }), + ]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "online", + environments, + shellSummary: EMPTY_SHELL_SUMMARY, + }); + + expect(state.connectingEnvironments).toHaveLength(1); + expect(state.connectingEnvironments[0]?.connectionState).toBe("reconnecting"); + expect(state.hasConnectingEnvironment).toBe(true); + expect(state.hasReadyEnvironment).toBe(true); + }); + + it("keeps retained snapshots visible while reconnecting without claiming readiness", () => { + const environments = [projectWorkspaceEnvironment(environment("reconnecting"))]; + const state = projectWorkspaceState({ + isReady: true, + networkStatus: "online", + environments, + shellSummary: CACHED_SHELL_SUMMARY, + }); + + expect(state.hasLoadedShellSnapshot).toBe(true); + expect(state.hasPendingShellSnapshot).toBe(true); + expect(state.hasReadyEnvironment).toBe(false); + expect(state.connectionState).toBe("reconnecting"); + }); +}); diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts new file mode 100644 index 00000000000..44c43d6c880 --- /dev/null +++ b/apps/mobile/src/state/workspaceModel.ts @@ -0,0 +1,107 @@ +import { type EnvironmentShellSummary } from "@t3tools/client-runtime/state/shell"; +import { type NetworkStatus } from "@t3tools/client-runtime/connection"; +import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; + +import type { EnvironmentPresentation } from "./environments"; + +export interface WorkspaceEnvironment { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string; + readonly displayUrl: string; + readonly isRelayManaged: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly connectionErrorTraceId: string | null; +} + +export interface WorkspaceState { + readonly isLoadingConnections: boolean; + readonly hasConnections: boolean; + readonly hasLoadedShellSnapshot: boolean; + readonly hasPendingShellSnapshot: boolean; + readonly hasReadyEnvironment: boolean; + readonly hasConnectingEnvironment: boolean; + readonly connectingEnvironments: ReadonlyArray; + readonly connectionState: EnvironmentConnectionPhase; + readonly connectionError: string | null; + readonly shellSnapshotError: string | null; + readonly latestCachedSnapshotReceivedAt: string | null; + readonly networkStatus: NetworkStatus; +} + +export function projectWorkspaceEnvironment( + environment: EnvironmentPresentation, +): WorkspaceEnvironment { + return { + environmentId: environment.environmentId, + environmentLabel: environment.label, + displayUrl: environment.displayUrl ?? "", + isRelayManaged: environment.relayManaged, + connectionState: environment.connection.phase, + connectionError: environment.connection.error, + connectionErrorTraceId: environment.connection.traceId, + }; +} + +function overallConnectionState( + environments: ReadonlyArray, + networkStatus: NetworkStatus, +): EnvironmentConnectionPhase { + if (environments.length === 0) { + return "available"; + } + if (networkStatus === "offline") { + return "offline"; + } + if (environments.some((environment) => environment.connectionState === "connected")) { + return "connected"; + } + if (environments.some((environment) => environment.connectionState === "reconnecting")) { + return "reconnecting"; + } + if (environments.some((environment) => environment.connectionState === "connecting")) { + return "connecting"; + } + if (environments.some((environment) => environment.connectionState === "error")) { + return "error"; + } + if (environments.some((environment) => environment.connectionState === "offline")) { + return "offline"; + } + return "available"; +} + +export function projectWorkspaceState(input: { + readonly isReady: boolean; + readonly networkStatus: NetworkStatus; + readonly environments: ReadonlyArray; + readonly shellSummary: EnvironmentShellSummary; +}): WorkspaceState { + const connectingEnvironments = input.environments.filter( + (environment) => + environment.connectionState === "connecting" || + environment.connectionState === "reconnecting", + ); + + return { + isLoadingConnections: !input.isReady, + hasConnections: input.environments.length > 0, + hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, + hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, + hasReadyEnvironment: + input.networkStatus !== "offline" && + input.environments.some((environment) => environment.connectionState === "connected"), + hasConnectingEnvironment: connectingEnvironments.length > 0, + connectingEnvironments, + connectionState: overallConnectionState(input.environments, input.networkStatus), + connectionError: + input.environments.find((environment) => environment.connectionError !== null) + ?.connectionError ?? null, + shellSnapshotError: input.shellSummary.firstError, + latestCachedSnapshotReceivedAt: input.shellSummary.latestSnapshotUpdatedAt, + networkStatus: input.networkStatus, + }; +} + +export type ServerConfigByEnvironmentId = ReadonlyMap; diff --git a/apps/mobile/src/widgets/AgentActivity.tsx b/apps/mobile/src/widgets/AgentActivity.tsx index 5cbd6c442f5..56ada5f2a02 100644 --- a/apps/mobile/src/widgets/AgentActivity.tsx +++ b/apps/mobile/src/widgets/AgentActivity.tsx @@ -58,9 +58,9 @@ export function AgentActivity( : "now"; const activeLabel = `${props.activeCount} active`; const isLight = environment.colorScheme === "light"; - const primaryForeground = isLight ? "#0f172a" : "#ffffff"; - const secondaryForeground = isLight ? "#475569" : "#cbd5e1"; - const mutedForeground = isLight ? "#64748b" : "#94a3b8"; + const primaryForeground = isLight ? "#262626" : "#f5f5f5"; + const secondaryForeground = isLight ? "#525252" : "#a3a3a3"; + const mutedForeground = isLight ? "#737373" : "#8e8e93"; const tint = environment.isLuminanceReduced ? secondaryForeground : row0?.phase === "waiting_for_approval" || row0?.phase === "waiting_for_input" diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts new file mode 100644 index 00000000000..9d431140d06 --- /dev/null +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -0,0 +1,153 @@ +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 canonicalHtmlPath = yield* fileSystem.realPath(htmlPath); + const canonicalCssPath = yield* fileSystem.realPath(cssPath); + + 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: canonicalHtmlPath, + }); + expect(yield* resolveAsset(token, "report.css")).toEqual({ + kind: "file", + path: canonicalCssPath, + }); + 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 canonicalFaviconPath = yield* fileSystem.realPath(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: canonicalFaviconPath }); + + 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/auth/http.ts b/apps/server/src/auth/http.ts index ed640863d21..6e1be00209d 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -25,6 +25,7 @@ import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Cookies from "effect/unstable/http/Cookies"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; @@ -33,6 +34,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -177,6 +179,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( ...session, scopes: new Set(session.scopes), }), + session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, ); }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); }), @@ -289,6 +292,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); }, + traceRelayRequest, Effect.catchTags({ ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 8ea7ca06f9a..78285eb7dcd 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -11,15 +11,12 @@ 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, - traceRelayBrokerHandler, -} from "./http.ts"; +import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; import { CloudManagedEndpointRuntime, type CloudManagedEndpointRuntimeShape, } from "./ManagedEndpointRuntime.ts"; +import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new ServerSecretStore.SecretStoreError({ @@ -75,8 +72,38 @@ describe("consumeCloudReplayGuards", () => { ); }); -describe("traceRelayBrokerHandler", () => { - it.effect("continues the incoming relay trace with the product tracer", () => +describe("relay request tracing", () => { + it.effect("does not accept an unauthenticated request trace parent", () => + 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* traceRelayRequest(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).not.toBe("0123456789abcdef0123456789abcdef"); + expect(Option.isNone(span.parent)).toBe(true); + }), + ); + + it.effect("continues an authenticated relay trace with the product tracer", () => Effect.gen(function* () { const spans: Array = []; const productTracer = Tracer.make({ @@ -94,7 +121,9 @@ describe("traceRelayBrokerHandler", () => { }), ); - yield* traceRelayBrokerHandler(Effect.void.pipe(Effect.withSpan("relay.mint.handler"))).pipe( + yield* traceAuthenticatedRelayRequest( + Effect.void.pipe(Effect.withSpan("relay.mint.handler")), + ).pipe( Effect.provideService(HttpServerRequest.HttpServerRequest, request), Effect.provideService(RelayClientTracer, Option.some(productTracer)), ); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index b78d47a20c1..89928ae13a2 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -48,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, HttpTraceContext } from "effect/unstable/http"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -77,6 +77,7 @@ import { relayUrlConfig } from "./publicConfig.ts"; import * as CliState from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; +import { traceRelayRequest } from "./traceRelayRequest.ts"; const CLOUD_MINT_NONCE_PREFIX = "cloud-mint-nonce-"; const CLOUD_MINT_JTI_PREFIX = "cloud-mint-jti-"; @@ -111,19 +112,6 @@ 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); } @@ -953,7 +941,7 @@ export const connectHttpApiLayer = HttpApiBuilder.group( .handle("health", ({ payload }) => cloudEnvironmentHealthHandler(dependencies, payload)) .handle("mintCredential", ({ payload }) => cloudMintCredentialHandler(dependencies, payload)) .handle("t3MintCredential", ({ payload }) => - traceRelayBrokerHandler(cloudMintCredentialHandler(dependencies, payload)), + traceRelayRequest(cloudMintCredentialHandler(dependencies, payload)), ); }), ); diff --git a/apps/server/src/cloud/traceRelayRequest.ts b/apps/server/src/cloud/traceRelayRequest.ts new file mode 100644 index 00000000000..1481b891224 --- /dev/null +++ b/apps/server/src/cloud/traceRelayRequest.ts @@ -0,0 +1,21 @@ +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpServerRequest, HttpTraceContext } from "effect/unstable/http"; + +export const traceRelayRequest = ( + effect: Effect.Effect, +): Effect.Effect => effect.pipe(withRelayClientTracing); + +export const traceAuthenticatedRelayRequest = ( + 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, + ); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 517d57168c3..37baff432fe 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -24,16 +24,15 @@ 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 { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, failEnvironmentScopeRequired, @@ -43,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"]); @@ -104,7 +101,7 @@ export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( Effect.fn("environment.metadata.descriptor")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); return yield* serverEnvironment.getDescriptor; - }), + }, traceRelayRequest), ); }), ); @@ -169,107 +166,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 = HttpRouter.add( 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/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index d0b6987c3b8..5a997de3669 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1877,6 +1877,142 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("clears stale pending user input from projected shell summaries", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-stale-user-input-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-stale-user-input"), + occurredAt: "2026-02-26T12:35:00.000Z", + commandId: CommandId.make("cmd-stale-user-input-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-user-input-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-stale-user-input"), + title: "Project Stale User Input", + workspaceRoot: "/tmp/project-stale-user-input", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-02-26T12:35:00.000Z", + updatedAt: "2026-02-26T12:35:00.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-stale-user-input-2"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-user-input"), + occurredAt: "2026-02-26T12:35:01.000Z", + commandId: CommandId.make("cmd-stale-user-input-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-user-input-2"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-user-input"), + projectId: ProjectId.make("project-stale-user-input"), + title: "Thread Stale User Input", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-02-26T12:35:01.000Z", + updatedAt: "2026-02-26T12:35:01.000Z", + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-user-input-3"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-user-input"), + occurredAt: "2026-02-26T12:35:02.000Z", + commandId: CommandId.make("cmd-stale-user-input-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-user-input-3"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-user-input"), + activity: { + id: EventId.make("activity-stale-user-input-requested"), + tone: "info", + kind: "user-input.requested", + summary: "User input requested", + payload: { + requestId: "user-input-request-stale-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + }, + ], + }, + turnId: null, + createdAt: "2026-02-26T12:35:02.000Z", + }, + }, + }); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-user-input-4"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-user-input"), + occurredAt: "2026-02-26T12:35:03.000Z", + commandId: CommandId.make("cmd-stale-user-input-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-user-input-4"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-user-input"), + activity: { + id: EventId.make("activity-stale-user-input-failed"), + tone: "error", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + payload: { + requestId: "user-input-request-stale-1", + detail: + "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: user-input-request-stale-1", + }, + turnId: null, + createdAt: "2026-02-26T12:35:03.000Z", + }, + }, + }); + + const threadRows = yield* sql<{ + readonly pendingUserInputCount: number; + }>` + SELECT pending_user_input_count AS "pendingUserInputCount" + FROM projection_threads + WHERE thread_id = 'thread-stale-user-input' + `; + assert.deepEqual(threadRows, [{ pendingUserInputCount: 0 }]); + }), + ); + it.effect("ignores non-stale provider approval response failures", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 8fedddcfbd8..f12df850941 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -165,7 +165,9 @@ function derivePendingUserInputCountFromActivities( activity.kind === "provider.user-input.respond.failed" && detail !== null && (detail.includes("stale pending user-input request") || - detail.includes("unknown pending user-input request")) + detail.includes("unknown pending user-input request") || + detail.includes("unknown pending user input request") || + detail.includes("unknown pending codex user input request")) ) { openRequestIds.delete(requestId); } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0d5cfe2feba..a08da26ba59 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -71,7 +71,7 @@ const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => async function waitFor( predicate: () => boolean | Promise, - timeoutMs = 2000, + timeoutMs = 10_000, ): Promise { const deadline = (await Effect.runPromise(Clock.currentTimeMillis)) + timeoutMs; const poll = async (): Promise => { @@ -1943,7 +1943,7 @@ describe("ProviderCommandReactor", () => { expect(resolvedActivity).toBeUndefined(); }); - it("surfaces stale provider user-input failures without faking user-input resolution", async () => { + it("surfaces non-resumable provider user-input callbacks as stale failures", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; harness.respondToUserInput.mockImplementation(() => @@ -1951,7 +1951,7 @@ describe("ProviderCommandReactor", () => { new ProviderAdapterRequestError({ provider: ProviderDriverKind.make("claudeAgent"), method: "item/tool/respondToUserInput", - detail: "Unknown pending user-input request: user-input-request-1", + detail: "Unknown pending Codex user input request: user-input-request-1", }), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e0db0fc320c..9c7a7c94bb1 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -141,9 +141,19 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause): boolean { const error = findProviderAdapterRequestError(cause); if (error) { - return error.detail.toLowerCase().includes("unknown pending user-input request"); + const detail = error.detail.toLowerCase(); + return ( + detail.includes("unknown pending user-input request") || + detail.includes("unknown pending user input request") || + detail.includes("unknown pending codex user input request") + ); } - return Cause.pretty(cause).toLowerCase().includes("unknown pending user-input request"); + const message = Cause.pretty(cause).toLowerCase(); + return ( + message.includes("unknown pending user-input request") || + message.includes("unknown pending user input request") || + message.includes("unknown pending codex user input request") + ); } function stalePendingRequestDetail( 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..8b37e86d8a9 --- /dev/null +++ b/apps/server/src/preview/PortScanner.test.ts @@ -0,0 +1,267 @@ +import * as net from "node:net"; + +import { it as effectIt } from "@effect/vitest"; +import { ThreadId } from "@t3tools/contracts"; +import * as Net from "@t3tools/shared/Net"; +import { Effect, Layer } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { ProcessRunner } from "../processRunner.ts"; +import * as PortScanner from "./PortScanner.ts"; + +const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = + PortScanner.__testing; +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)), +); + +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 windowsPlatform = Effect.acquireRelease( + Effect.sync(() => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + return originalPlatform; + }), + (originalPlatform) => + Effect.sync(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + }), +); + +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), +); + +describe("parsePortFromLsofName", () => { + it("parses *:port", () => { + expect(parsePortFromLsofName("*:5173")).toBe(5173); + }); + + it("parses 127.0.0.1:port", () => { + expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173); + }); + + it("parses localhost:port", () => { + expect(parsePortFromLsofName("localhost:5173")).toBe(5173); + }); + + it("parses [::1]:port", () => { + expect(parsePortFromLsofName("[::1]:5173")).toBe(5173); + }); + + it("ignores non-local hosts", () => { + expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull(); + }); + + it("strips trailing description", () => { + expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173); + }); + + it("rejects garbage", () => { + expect(parsePortFromLsofName("")).toBeNull(); + expect(parsePortFromLsofName("not-a-port")).toBeNull(); + expect(parsePortFromLsofName("*:0")).toBeNull(); + expect(parsePortFromLsofName("*:99999")).toBeNull(); + }); +}); + +describe("parseLsofOutput", () => { + it("parses a typical lsof -F pcn output", () => { + const sample = [ + "p12345", + "cnode", + "n*:5173", + "p67890", + "cnext-server", + "n127.0.0.1:3000", + "n127.0.0.1:9229", // node debug port too — same process + "p13579", + "cChrome", + "n192.168.1.10:443", // not local — ignored + ].join("\n"); + + const servers = parseLsofOutput(sample); + expect(servers).toEqual([ + { + host: "localhost", + port: 3000, + url: "http://localhost:3000", + processName: "next-server", + pid: 67890, + terminal: null, + }, + { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + terminal: null, + }, + { + host: "localhost", + port: 9229, + url: "http://localhost:9229", + processName: "next-server", + pid: 67890, + terminal: null, + }, + ]); + }); + + it("handles empty input", () => { + expect(parseLsofOutput("")).toEqual([]); + }); + + it("dedupes by host:port", () => { + const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n"); + const servers = parseLsofOutput(sample); + expect(servers).toHaveLength(1); + expect(servers[0]?.port).toBe(5173); + }); + + it("attributes listeners to a registered terminal process", () => { + const servers = parseLsofOutput( + ["p12345", "cnode", "n*:5173"].join("\n"), + new Map([ + [ + 12345, + { + threadId: ThreadId.make("thread-1"), + terminalId: "terminal-1", + }, + ], + ]), + ); + + expect(servers[0]?.terminal).toEqual({ + threadId: "thread-1", + terminalId: "terminal-1", + }); + }); +}); + +describe("serversEqual", () => { + const a = { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 1, + terminal: null, + }; + it("returns true for identical lists", () => { + expect(serversEqual([a], [{ ...a }])).toBe(true); + }); + it("returns false for different lengths", () => { + expect(serversEqual([a], [])).toBe(false); + }); + it("returns false for different processName", () => { + expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false); + }); +}); + +describe("parseWindowsListenerOutput", () => { + it("parses and attributes PowerShell listener records", () => { + const servers = parseWindowsListenerOutput( + "0.0.0.0|5173|12345|node", + new Map([ + [ + 12345, + { + threadId: ThreadId.make("thread-1"), + terminalId: "terminal-1", + }, + ], + ]), + ); + + expect(servers).toEqual([ + { + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + terminal: { + threadId: "thread-1", + terminalId: "terminal-1", + }, + }, + ]); + }); +}); + +/** + * Integration tests against a real TCP listener. We force the Windows code + * path (TCP-probe fallback) by monkey-patching `process.platform` for the + * duration of the test so we don't depend 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* () { + yield* windowsPlatform; + 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* () { + yield* windowsPlatform; + 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..c8d9a051ed6 --- /dev/null +++ b/apps/server/src/preview/PortScanner.ts @@ -0,0 +1,369 @@ +/** + * 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 * 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 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 (process.platform === "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); + +/** Exposed for tests. */ +export const __testing = { + parseLsofOutput, + parsePortFromLsofName, + parseWindowsListenerOutput, + serversEqual, +}; 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/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/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 38b77c69262..c91f305b174 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 { getClaudeModelCapabilities, @@ -3445,6 +3446,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 } : {}), @@ -3470,6 +3472,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/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 04ef44d54e8..7fef85c42e0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -491,6 +491,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 8c9969e2bc4..270126e934b 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, @@ -234,7 +235,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"; @@ -475,7 +479,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 } : {}), }, @@ -1382,6 +1386,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, @@ -1397,6 +1402,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/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 f9b9c6ab4fb..e3e20106d72 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -62,6 +62,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, }); @@ -103,6 +107,7 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly appServerArgs?: ReadonlyArray; } export interface CodexSessionRuntimeSendTurnInput { @@ -720,7 +725,7 @@ export const makeCodexSessionRuntime = ( }; const child = yield* spawner .spawn( - ChildProcess.make(options.binaryPath, ["app-server"], { + ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { cwd: options.cwd, env, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, @@ -1255,6 +1260,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 cdb3c224b97..1560332ad7f 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 { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -530,6 +531,7 @@ export function makeCursorAdapter( ? yield* options.resolveSettings : cursorSettings; + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -537,6 +539,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/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 0f1007f261b..a21a2bb9fc7 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, @@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, }); + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -381,6 +383,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/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 54444ce586d..1eb6e47bc19 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 { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -1053,6 +1054,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/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2bce1f483b7..ecb1dd2dbd3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -56,6 +56,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); /** @@ -212,6 +214,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( @@ -383,16 +397,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}'.`, @@ -572,14 +590,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}'.`, @@ -827,6 +849,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, @@ -998,6 +1021,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 4ed64890fc3..47a8c845e56 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -47,6 +47,7 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -400,7 +401,7 @@ const makeAcpSessionRuntime = ( const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.LoadSessionRequest; const resumed = yield* runLoggedRequest( "session/load", @@ -413,7 +414,7 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, - mcpServers: [], + mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( "session/new", @@ -426,7 +427,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/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index bbfbd236ad0..26339e54aef 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -95,6 +95,27 @@ function makeMemorySecretStore() { } describe.sequential("signRelayAgentActivityPublishProof", () => { + it("distinguishes pending link credentials from disabled publication", () => { + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: false, + publishEnabled: false, + }), + ).toBe("waiting-for-link"); + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: true, + publishEnabled: false, + }), + ).toBe("disabled"); + expect( + AgentAwarenessRelay.resolveAgentActivityPublishingStartupState({ + relayConfigured: true, + publishEnabled: true, + }), + ).toBe("enabled"); + }); + it("derives the thread id from the aggregate id for thread events without payload thread ids", () => { const threadId = "thread-aggregate-1" as ThreadId; const now = "2026-05-25T00:00:00.000Z"; diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 960f27e752b..725eed57db0 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -99,6 +99,16 @@ export function isAgentActivityPublishingEnabled(value: string | null): boolean return value === "true"; } +export function resolveAgentActivityPublishingStartupState(input: { + readonly relayConfigured: boolean; + readonly publishEnabled: boolean; +}): "waiting-for-link" | "disabled" | "enabled" { + if (!input.relayConfigured) { + return "waiting-for-link"; + } + return input.publishEnabled ? "enabled" : "disabled"; +} + const RELAY_AGENT_ACTIVITY_DETAIL_MAX_LENGTH = 160; const REDACTED_RELAY_AGENT_FAILURE_DETAIL = "The agent run failed."; @@ -303,7 +313,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity publish skipped; T3 Connect config missing", { + yield* Effect.logDebug("agent activity publish skipped; relay link credentials unavailable", { threadId, }); return; @@ -421,7 +431,7 @@ const make = Effect.gen(function* () { } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { - yield* Effect.logDebug("agent activity snapshot skipped; T3 Connect config missing"); + yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); return false; } const environmentId = yield* serverEnvironment.getEnvironmentId; @@ -442,31 +452,55 @@ const make = Effect.gen(function* () { return true; }); - const publishActiveThreadsOnceWhenConfigured = Effect.gen(function* () { - while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); - if (published) { - yield* Ref.set(activeSnapshotPublishedRef, true); - return; + const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => + Effect.gen(function* () { + while (!(yield* Ref.get(activeSnapshotPublishedRef))) { + const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); + if (published) { + yield* Ref.set(activeSnapshotPublishedRef, true); + if (logEnabledWhenReady) { + const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); + yield* Effect.logInfo("agent activity publishing enabled after link reconciliation", { + relayUrl: relayConfig?.url, + }); + } + return; + } + yield* Effect.sleep("5 seconds"); } - yield* Effect.sleep("5 seconds"); - } - }); + }); const worker = yield* makeDrainableWorker(publishThread); const start: AgentAwarenessRelayShape["start"] = Effect.fn("AgentAwarenessRelay.start")( function* () { - const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); - if (!relayConfig) { - yield* Effect.logInfo("agent activity publishing standby; T3 Connect config missing"); - } else { - yield* Effect.logInfo("agent activity publishing enabled", { - relayUrl: relayConfig.url, - }); + const [relayConfig, publishEnabled] = yield* Effect.all([ + readRelayConfig.pipe(Effect.orElseSucceed(() => null)), + readPublishAgentActivityEnabled.pipe(Effect.orElseSucceed(() => false)), + ]); + const startupState = resolveAgentActivityPublishingStartupState({ + relayConfigured: relayConfig !== null, + publishEnabled, + }); + switch (startupState) { + case "waiting-for-link": + yield* Effect.logInfo( + "agent activity publishing standby; waiting for T3 Connect link reconciliation", + ); + break; + case "disabled": + yield* Effect.logInfo("agent activity publishing disabled by T3 Connect configuration"); + break; + case "enabled": + yield* Effect.logInfo("agent activity publishing enabled", { + relayUrl: relayConfig?.url, + }); + break; } yield* Effect.forkScoped( - Effect.sleep("1 second").pipe(Effect.andThen(publishActiveThreadsOnceWhenConfigured)), + Effect.sleep("1 second").pipe( + Effect.andThen(publishActiveThreadsOnceWhenConfigured(startupState !== "enabled")), + ), ); yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0bf2f6589f0..40c9c7cd9a8 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, @@ -48,6 +49,7 @@ 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 { ChildProcessSpawner } from "effect/unstable/process"; import { @@ -69,7 +71,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, @@ -97,6 +98,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, @@ -512,7 +515,7 @@ const buildAppUnderTest = (options?: { Layer.provide(WorkspacePathsLive), Layer.provide(workspaceEntriesLayer), ), - ProjectFaviconResolverLive, + ProjectFaviconResolverLive.pipe(Layer.provide(WorkspacePathsLive)), ); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), @@ -663,6 +666,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, @@ -1252,61 +1278,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(); @@ -3168,28 +3139,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]({}))), @@ -3733,29 +3686,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(); @@ -3826,60 +3756,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<{ @@ -4169,22 +4045,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 = { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index efe2f5d11e4..58cf9a1fcb6 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"; @@ -88,6 +92,12 @@ import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; +// Effect's default preemptive shutdown waits 20s before finalizing request scopes. +// T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer +// already closes the websocket gracefully. Do not add an artificial drain before +// those finalizers get a chance to run. +const HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS = 0; + const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { @@ -116,7 +126,8 @@ const HttpServerLive = Layer.unwrap( ); return BunHttpServer.layer({ port: config.port, - ...(config.host ? { hostname: config.host } : {}), + hostname: config.host ?? "127.0.0.1", + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, }); } else { const [NodeHttpServer, NodeHttp] = yield* Effect.all([ @@ -124,8 +135,9 @@ const HttpServerLive = Layer.unwrap( Effect.promise(() => import("node:http")), ]); return NodeHttpServer.layer(NodeHttp.createServer, { - host: config.host, + host: config.host ?? "127.0.0.1", port: config.port, + gracefulShutdownTimeout: HTTP_PREEMPTIVE_SHUTDOWN_GRACE_MS, }); } }), @@ -224,7 +236,17 @@ const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); -const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); +const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); + +const TerminalLayerLive = TerminalManagerLive.pipe( + Layer.provide(PtyAdapterLive), + Layer.provide(PortScannerLayerLive), +); + +const PreviewLayerLive = Layer.empty.pipe( + Layer.provideMerge(PreviewManager.layer), + Layer.provideMerge(PortScannerLayerLive), +); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), @@ -242,6 +264,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), @@ -267,7 +293,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), @@ -291,7 +317,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), @@ -320,18 +346,20 @@ 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( diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 2ebf8481957..30515ca2c47 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -204,6 +204,7 @@ interface CreateManagerOptions { subprocessInspector?: (terminalPid: number) => Effect.Effect<{ readonly hasRunningSubprocess: boolean; readonly childCommand: string | null; + readonly processIds: ReadonlyArray; }>; subprocessPollIntervalMs?: number; processKillGraceMs?: number; @@ -745,7 +746,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, @@ -754,7 +756,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( @@ -767,7 +769,7 @@ it.layer( "1200 millis", ); - inspect = { hasRunningSubprocess: false, childCommand: null }; + inspect = { hasRunningSubprocess: false, childCommand: null, processIds: [] }; yield* waitFor( Effect.map(getEvents, (events) => events.some( @@ -788,7 +790,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, }); diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index cd490de1e3f..f2b466a4390 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -33,6 +33,7 @@ import { terminalSessionsTotal, } from "../../observability/Metrics.ts"; import * as ProcessRunner from "../../processRunner.ts"; +import * as PortScanner from "../../preview/PortScanner.ts"; import { TerminalCwdError, TerminalHistoryError, @@ -82,6 +83,7 @@ class TerminalProcessSignalError extends Schema.TaggedErrorClass; } interface TerminalSubprocessInspector { @@ -505,12 +507,8 @@ 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({ @@ -524,16 +522,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 name = result.stdout.trim().split(/\r?\n/)[0]?.trim() ?? ""; - if (name.length === 0) { - return { hasRunningSubprocess: true, childCommand: null } 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 normalized = normalizeChildCommandName(name, platform); + const directChildren = childrenByParent.get(terminalPid) ?? []; + const childPid = directChildren[0]; + if (childPid === undefined) { + return { hasRunningSubprocess: false, childCommand: null, processIds: [] } as const; + } + 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( @@ -606,14 +629,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); @@ -628,7 +651,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({ @@ -663,16 +686,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); @@ -932,14 +982,26 @@ interface TerminalManagerOptions { subprocessPollIntervalMs?: number; processKillGraceMs?: number; maxRetainedInactiveSessions?: number; + 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 portDiscovery = yield* PortScanner.PortDiscovery; return yield* makeTerminalManagerWithOptions({ logsDir: terminalLogsDir, ptyAdapter, + registerTerminalProcesses: portDiscovery.registerTerminalProcesses, + unregisterTerminal: portDiscovery.unregisterTerminal, }); }); @@ -967,6 +1029,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); @@ -1495,6 +1559,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, @@ -1531,6 +1599,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(); }); @@ -1700,6 +1772,10 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith advanceEventSequence(session); return [undefined, state] as const; }); + yield* unregisterTerminal({ + threadId: session.threadId, + terminalId: session.terminalId, + }); yield* evictInactiveSessionsIfNeeded(); @@ -1731,6 +1807,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); } @@ -1791,6 +1868,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/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 42fda3c80b2..33d009b9dc2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1494,7 +1494,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const stagedPatch = yield* runGitStdoutWithOptions( "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, - ["diff", "--cached", "--patch", "--minimal"], + ["diff", "--no-ext-diff", "--cached", "--patch", "--minimal"], { maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -1731,7 +1731,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--patch", "--minimal", range], + ["diff", "--no-ext-diff", "--patch", "--minimal", range], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..2823923e033 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, @@ -39,6 +40,7 @@ import { type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, FilesystemBrowseError, + AssetAccessError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -70,6 +72,10 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; import { TerminalManager } from "./terminal/Services/Manager.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 { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; @@ -158,6 +164,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [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 +187,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,6 +259,9 @@ 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; @@ -1184,6 +1206,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 +1418,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 +1615,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/web/package.json b/apps/web/package.json index cbf554a6679..9d815d88887 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,8 +14,8 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", - "@clerk/clerk-js": "^6.13.0", - "@clerk/react": "^6.7.2", + "@clerk/clerk-js": "^6.16.0", + "@clerk/react": "^6.9.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts new file mode 100644 index 00000000000..7d783458884 --- /dev/null +++ b/apps/web/src/assets/assetUrls.ts @@ -0,0 +1,107 @@ +import { useAtomSet } from "@effect/atom-react"; +import type { AssetCreateUrlResult, AssetResource, EnvironmentId } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { assetEnvironment } from "~/state/assets"; +import { usePreparedConnection } from "~/state/session"; + +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(input: { + readonly environmentId: EnvironmentId; + readonly httpBaseUrl: string; + readonly resource: AssetResource; + readonly createUrl: (input: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Promise; +}): Promise { + const key = assetCacheKey(input.environmentId, input.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 = input + .createUrl({ + environmentId: input.environmentId, + input: { resource: input.resource }, + }) + .then((result) => { + const cachedResult = { + url: new URL(result.relativeUrl, input.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 createUrl = useAtomSet(assetEnvironment.createUrl, { mode: "promise" }); + const preparedConnection = usePreparedConnection(environmentId); + 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(() => { + if (preparedConnection._tag === "None") { + setUrl(null); + return; + } + let cancelled = false; + let refreshTimer: ReturnType | undefined; + const httpBaseUrl = preparedConnection.value.httpBaseUrl; + + const load = () => { + void resolveAssetUrl({ + environmentId, + httpBaseUrl, + resource: stableResource, + createUrl, + }) + .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); + }; + }, [createUrl, environmentId, key, preparedConnection, 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..d5a44119e9c --- /dev/null +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +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..2305812784f --- /dev/null +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -0,0 +1,73 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const readPreparedConnection = vi.fn(); + +vi.mock("~/state/session", () => ({ readPreparedConnection })); + +describe("browser target resolver", () => { + beforeEach(() => readPreparedConnection.mockReset()); + + it("maps environment ports onto a private network host", async () => { + readPreparedConnection.mockReturnValue({ 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 () => { + readPreparedConnection.mockReturnValue({ 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 () => { + readPreparedConnection.mockReturnValue({ 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 () => { + readPreparedConnection.mockReturnValue({ 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..0a6dc3aa7c2 --- /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 { readPreparedConnection } from "~/state/session"; + +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 = readPreparedConnection(environmentId); + if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); + const environmentUrl = new URL(connection.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..63306a91dd8 --- /dev/null +++ b/apps/web/src/browser/openFileInPreview.ts @@ -0,0 +1,64 @@ +import type { + AssetCreateUrlResult, + AssetResource, + EnvironmentId, + PreviewOpenInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; + +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 type OpenPreviewMutation = (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewOpenInput; +}) => Promise; + +export async function openUrlInPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly url: string; + readonly openPreview: OpenPreviewMutation; +}): Promise { + const snapshot = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { threadId: input.threadRef.threadId, url: input.url }, + }); + usePreviewStateStore.getState().applyServerSnapshot(input.threadRef, snapshot); + usePreviewStateStore.getState().rememberUrl(input.threadRef, input.url); + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); +} + +export async function openFileInPreview(input: { + readonly threadRef: ScopedThreadRef; + readonly filePath: string; + readonly httpBaseUrl: string; + readonly createAssetUrl: (input: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Promise; + readonly openPreview: OpenPreviewMutation; +}): Promise { + if (!isPreviewSupportedInRuntime()) { + throw new Error("The integrated browser is unavailable in this runtime."); + } + const asset = await resolveAssetUrl({ + environmentId: input.threadRef.environmentId, + httpBaseUrl: input.httpBaseUrl, + resource: { + _tag: "workspace-file", + threadId: input.threadRef.threadId, + path: input.filePath, + }, + createUrl: input.createAssetUrl, + }); + await openUrlInPreview({ + threadRef: input.threadRef, + url: asset.url, + openPreview: input.openPreview, + }); +} 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/dpop.test.ts b/apps/web/src/cloud/dpop.test.ts index 754930d0ced..75951db1baf 100644 --- a/apps/web/src/cloud/dpop.test.ts +++ b/apps/web/src/cloud/dpop.test.ts @@ -1,32 +1,35 @@ import { verifyDpopProof } from "@t3tools/shared/dpop"; +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { decodeJwt } from "jose"; +import { vi } from "vite-plus/test"; import { browserCryptoLayer, createBrowserDpopProof, generateBrowserDpopKey } from "./dpop"; describe("browser DPoP proofs", () => { - it("signs relay resource proofs with an access-token hash", async () => { - vi.stubGlobal("indexedDB", undefined); - const issuedAt = Math.floor(Date.now() / 1_000); - const proofKey = await Effect.runPromise(generateBrowserDpopKey); - const proof = await Effect.runPromise( - createBrowserDpopProof({ + it.effect("signs relay resource proofs with an access-token hash", () => + Effect.gen(function* () { + vi.stubGlobal("indexedDB", undefined); + const proofKey = yield* generateBrowserDpopKey; + const proof = yield* createBrowserDpopProof({ method: "POST", url: "https://relay.example.test/v1/environments/env-1/connect?ignored=true", accessToken: "relay-access-token", proofKey, - }).pipe(Effect.provide(browserCryptoLayer)), - ); + }).pipe(Effect.provide(browserCryptoLayer)); + const issuedAt = decodeJwt(proof.proof).iat; + expect(issuedAt).toBeTypeOf("number"); - expect( - verifyDpopProof({ - proof: proof.proof, - method: "POST", - url: "https://relay.example.test/v1/environments/env-1/connect", - expectedThumbprint: proof.thumbprint, - expectedAccessToken: "relay-access-token", - nowEpochSeconds: issuedAt, - }), - ).toMatchObject({ ok: true }); - }); + expect( + verifyDpopProof({ + proof: proof.proof, + method: "POST", + url: "https://relay.example.test/v1/environments/env-1/connect", + expectedThumbprint: proof.thumbprint, + expectedAccessToken: "relay-access-token", + nowEpochSeconds: issuedAt!, + }), + ).toMatchObject({ ok: true }); + }), + ); }); diff --git a/apps/web/src/cloud/linkEnvironment.test.ts b/apps/web/src/cloud/linkEnvironment.test.ts index 30cb596781a..b4a347fca9b 100644 --- a/apps/web/src/cloud/linkEnvironment.test.ts +++ b/apps/web/src/cloud/linkEnvironment.test.ts @@ -1,911 +1,323 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { + EnvironmentId, + type RelayClientInstallProgressEvent, + WS_METHODS, +} from "@t3tools/contracts"; import { RelayWebClientId } from "@t3tools/contracts/relay"; -import { afterEach, beforeEach, vi } from "vite-plus/test"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { HttpClient } from "effect/unstable/http"; +import { afterEach, beforeEach, vi } from "vite-plus/test"; +import { + AVAILABLE_CONNECTION_STATE, + type EnvironmentRegistryService, + EnvironmentSupervisor, + type EnvironmentSupervisorService, + type PreparedConnection, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { type RpcSession } from "@t3tools/client-runtime/rpc"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import { managedRelayClientLayer, ManagedRelayClient, ManagedRelayDpopSigner, - remoteHttpClientLayer, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; -import type { SavedEnvironmentRecord } from "../environments/runtime"; import { - connectManagedCloudEnvironment, - linkEnvironmentToCloud, + collectCloudLinkTargets, linkPrimaryEnvironmentToCloud, listManagedCloudEnvironments, normalizeRelayBaseUrl, readPrimaryCloudLinkState, + type CloudLinkTarget, unlinkPrimaryEnvironmentFromCloud, } from "./linkEnvironment"; -import { - readPrimaryEnvironmentDescriptor, - readPrimaryEnvironmentTarget, - resolvePrimaryEnvironmentHttpUrl, -} from "../environments/primary"; -const getSavedEnvironmentSecretMock = vi.fn(); -const relayClientInstallDialogHarness = vi.hoisted(() => ({ +const TARGET: CloudLinkTarget = { + environmentId: "environment-1", + label: "Desktop", + httpBaseUrl: "http://127.0.0.1:3000", + wsBaseUrl: "ws://127.0.0.1:3000", +}; + +const relayClientInstallDialog = vi.hoisted(() => ({ requestConfirmation: vi.fn(), reportProgress: vi.fn(), finish: vi.fn(), })); -const getRelayClientStatusMock = vi.fn(); -const installRelayClientMock = vi.fn(); -const environmentConnectionMock = { - client: { - cloud: { - getRelayClientStatus: getRelayClientStatusMock, - installRelayClient: installRelayClientMock, - }, - }, -}; -const createProofMock = vi.fn( - (_input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => - Effect.succeed("web-dpop-proof"), -); -const testDpopSignerLayer = Layer.succeed( +vi.mock("./relayClientInstallDialog", () => ({ + requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation, + reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress, + finishRelayClientInstall: relayClientInstallDialog.finish, +})); + +const createProof = vi.fn(() => Effect.succeed("dpop-proof")); +const dpopSignerLayer = Layer.succeed( ManagedRelayDpopSigner, ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("web-thumbprint"), - createProof: (input) => createProofMock(input), + thumbprint: Effect.succeed("thumbprint"), + createProof, }), ); -function cloudClientLayer() { - const httpClientLayer = remoteHttpClientLayer(globalThis.fetch); +function relayLayer() { + const http = remoteHttpClientLayer(globalThis.fetch); return Layer.mergeAll( - httpClientLayer, + http, managedRelayClientLayer({ relayUrl: "https://relay.example.test", clientId: RelayWebClientId, - }).pipe(Layer.provideMerge(testDpopSignerLayer), Layer.provide(httpClientLayer)), + }).pipe(Layer.provideMerge(dpopSignerLayer), Layer.provide(http)), ); } -const withCloudServices = ( - effect: Effect.Effect, -) => effect.pipe(Effect.provide(cloudClientLayer())); - -vi.mock("../localApi", () => ({ - ensureLocalApi: () => ({ - persistence: { - getSavedEnvironmentSecret: getSavedEnvironmentSecretMock, - }, - }), -})); - -vi.mock("./relayClientInstallDialog", () => ({ - requestRelayClientInstallConfirmation: relayClientInstallDialogHarness.requestConfirmation, - reportRelayClientInstallProgress: relayClientInstallDialogHarness.reportProgress, - finishRelayClientInstall: relayClientInstallDialogHarness.finish, -})); - -vi.mock("../environments/primary", () => ({ - readPrimaryEnvironmentDescriptor: vi.fn(() => null), - readPrimaryEnvironmentTarget: vi.fn(() => null), - resolvePrimaryEnvironmentHttpUrl: vi.fn((path: string) => `http://127.0.0.1:3000${path}`), -})); - -vi.mock("../environments/runtime", () => ({ - getPrimaryEnvironmentConnection: () => environmentConnectionMock, - readEnvironmentConnection: () => environmentConnectionMock, -})); - -const savedEnvironment: SavedEnvironmentRecord = { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - createdAt: "2026-05-25T00:00:00.000Z", - lastConnectedAt: null, -}; - -function validProof() { - return "signed-environment-link-jwt"; +function registryLayer(options?: { + readonly status?: { readonly status: "available"; readonly version: string }; + readonly installEvents?: ReadonlyArray; +}) { + return Layer.effect( + EnvironmentRegistry, + Effect.gen(function* () { + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }), + [WS_METHODS.cloudInstallRelayClient]: () => + Stream.fromIterable(options?.installEvents ?? []), + } as unknown as RpcSession["client"]; + const session: RpcSession = { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const target = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make(TARGET.environmentId), + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + wsBaseUrl: TARGET.wsBaseUrl, + }); + const supervisor = EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisorService); + const registry = { + run: (_environmentId: EnvironmentId, effect: Effect.Effect) => + Effect.provideService(effect, EnvironmentSupervisor, supervisor), + runStream: (_environmentId: EnvironmentId, stream: Stream.Stream) => + Stream.provideService(stream, EnvironmentSupervisor, supervisor), + } as unknown as EnvironmentRegistryService; + return EnvironmentRegistry.of(registry); + }), + ); } -function validChallenge() { - return { - challenge: "link-challenge", - expiresAt: "2026-05-25T00:05:00.000Z", - }; +function services(options?: Parameters[0]) { + return Layer.mergeAll(relayLayer(), registryLayer(options)); } -function availableRelayClient() { - return { - status: "available", - executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", - source: "managed", - version: "2026.5.2", - }; +function withServices( + effect: Effect.Effect, + options?: Parameters[0], +) { + return effect.pipe(Effect.provide(services(options))); } -function requestBodyText(body: BodyInit | null | undefined): string { +function bodyText(body: BodyInit | null | undefined): string { return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); } -describe("web cloud link environment client", () => { - afterEach(() => { - if ("window" in globalThis) { - Reflect.deleteProperty(window, "desktopBridge"); - } - vi.unstubAllGlobals(); - }); +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); + relayClientInstallDialog.requestConfirmation.mockResolvedValue(true); +}); - beforeEach(() => { - vi.restoreAllMocks(); - vi.clearAllMocks(); - createProofMock.mockClear(); - vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test"); - getSavedEnvironmentSecretMock.mockResolvedValue("local-bearer"); - relayClientInstallDialogHarness.requestConfirmation.mockResolvedValue(true); - getRelayClientStatusMock.mockResolvedValue(availableRelayClient()); - installRelayClientMock.mockResolvedValue(availableRelayClient()); - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue(null); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue(null); - vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( - (path: string) => `http://127.0.0.1:3000${path}`, - ); - }); +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); - it("normalizes configured relay base URLs before building relay requests", () => { +describe("web cloud link environment client", () => { + it("normalizes relay URLs and de-duplicates cloud link targets", () => { expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( "https://relay.example.test", ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); + expect(normalizeRelayBaseUrl(" ")).toBeNull(); + expect( + collectCloudLinkTargets({ + primary: TARGET, + saved: [TARGET, { ...TARGET, environmentId: "environment-2" }], + }).map((target) => target.environmentId), + ).toEqual(["environment-1", "environment-2"]); }); - it.effect( - "installs the relay client over environment RPC before requesting a cloud challenge", - () => - Effect.gen(function* () { - getRelayClientStatusMock.mockResolvedValue({ - status: "missing", - version: "2026.5.2", - }); - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json({ malformed: true })); - vi.stubGlobal("fetch", fetchMock); - installRelayClientMock.mockImplementationOnce(async (onProgress) => { - onProgress({ type: "progress", stage: "downloading" }); - return availableRelayClient(); - }); - - yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - - expect(relayClientInstallDialogHarness.requestConfirmation).toHaveBeenCalledWith( - "2026.5.2", - ); - expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); - expect(installRelayClientMock).toHaveBeenCalledOnce(); - expect(relayClientInstallDialogHarness.reportProgress).toHaveBeenCalledWith({ - type: "progress", - stage: "downloading", - }); - expect(relayClientInstallDialogHarness.finish).toHaveBeenCalledOnce(); - expect(installRelayClientMock.mock.invocationCallOrder[0]).toBeLessThan( - fetchMock.mock.invocationCallOrder[0]!, - ); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-link-challenges", - ); - }), - ); - - it.effect("lists relay-managed environments for hosted and served web clients", () => + it.effect("lists relay-managed environments through the typed relay client", () => Effect.gen(function* () { - const fetchMock = vi.fn().mockResolvedValueOnce( + const fetchMock = vi.fn().mockResolvedValue( Response.json({ environments: [ { - environmentId: "env-1", - label: "Managed desktop", + environmentId: "environment-1", + label: "Desktop", endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", + httpBaseUrl: "https://desktop.example.test", + wsBaseUrl: "wss://desktop.example.test", providerKind: "cloudflare_tunnel", }, - linkedAt: "2026-05-25T00:00:00.000Z", + linkedAt: "2026-06-06T00:00:00.000Z", }, ], }), ); vi.stubGlobal("fetch", fetchMock); - const environments = yield* withCloudServices( + const environments = yield* withServices( listManagedCloudEnvironments({ clerkToken: "clerk-token" }), ); + expect(environments).toHaveLength(1); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/environments", - ); expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); - }), - ); - - it.effect("connects web clients to managed environments with a tunnel-only DPoP token", () => - Effect.gen(function* () { - const environment = { - environmentId: EnvironmentId.make("env-1"), - label: "Managed desktop", - endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", - providerKind: "cloudflare_tunnel" as const, - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "relay-access-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope: "environment:connect", - }), - ) - .mockResolvedValueOnce( - Response.json({ - environmentId: "env-1", - endpoint: environment.endpoint, - credential: "environment-bootstrap", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ) - .mockResolvedValueOnce( - Response.json({ - environmentId: "env-1", - label: "Managed desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ) - .mockResolvedValueOnce( - Response.json({ - access_token: "environment-access-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 3600, - scope: "orchestration:read orchestration:operate terminal:operate review:write", - }), - ); - vi.stubGlobal("fetch", fetchMock); - - const connection = yield* withCloudServices( - connectManagedCloudEnvironment({ clerkToken: "clerk-token", environment }), - ); - expect(connection).toMatchObject({ - environmentId: "env-1", - accessToken: "environment-access-token", - }); - - const tokenBody = requestBodyText(fetchMock.mock.calls[0]?.[1]?.body); - expect(new URLSearchParams(tokenBody).get("client_id")).toBe("t3-web"); - expect(new URLSearchParams(tokenBody).get("scope")).toBe("environment:connect"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("DPoP relay-access-token"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.dpop).toBe("web-dpop-proof"); - expect(createProofMock).toHaveBeenCalledWith({ - 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], - ); - }), - ); - - it.effect("rejects a stored managed connection for another relay origin", () => - Effect.gen(function* () { - const environment = { - environmentId: EnvironmentId.make("env-1"), - label: "Managed desktop", - endpoint: { - httpBaseUrl: "https://managed.example.test", - wsBaseUrl: "wss://managed.example.test", - providerKind: "cloudflare_tunnel" as const, - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }; - - const error = yield* withCloudServices( - connectManagedCloudEnvironment({ - clerkToken: "clerk-token", - environment, - relayUrl: "https://old-relay.example.test", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - message: "The saved environment is linked through a different configured relay.", - }); - }), - ); - - it.effect("rejects malformed local environment link proofs", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce( - Response.json({ - payload: { - environmentId: "env-1", - }, - signature: "signature-1", - }), - ), - ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Could not obtain environment link proof.", - }); }), ); - it.effect("preserves typed local environment failures while obtaining a link proof", () => + it.effect("reads primary cloud link state from the explicit target", () => Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce( - Response.json( - { - _tag: "EnvironmentHttpUnauthorizedError", - message: "Invalid environment bearer session.", - }, - { status: 401 }, - ), - ), - ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error._tag).toBe("CloudEnvironmentLinkError"); - expect(error.message).toBe( - "Could not obtain environment link proof: Invalid environment bearer session.", - ); - }), - ); - - it.effect("rejects malformed relay environment link responses", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ), - ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/client/environment-links failed", - }); - }), - ); - - it.effect( - "links the primary local environment through the relay using the owner cookie session", - () => - Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - vi.mocked(resolvePrimaryEnvironmentHttpUrl).mockImplementation( - (path: string) => `http://127.0.0.1:3000${path}`, - ); - - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - tunnelId: "tunnel-id", - tunnelName: "tunnel-name", - }, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ) - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), - ); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", - }), - ); - - expect(getRelayClientStatusMock).toHaveBeenCalledOnce(); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-link-challenges", - ); - expect(fetchMock.mock.calls[0]?.[1]?.method).toBe("POST"); - expect(fetchMock.mock.calls[0]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[0]?.[1]?.credentials).not.toBe("include"); - - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-proof", - ); - expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - headers: expect.objectContaining({ - "content-type": "application/json", - }), - }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ - challenge: "link-challenge", - endpoint: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - providerKind: "cloudflare_tunnel", - }, - origin: { - localHttpHost: "127.0.0.1", - localHttpPort: 3000, - }, - }); - - expect(String(fetchMock.mock.calls[2]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-links", - ); - expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); - expect(fetchMock.mock.calls[2]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); - expect(fetchMock.mock.calls[2]?.[1]?.credentials).not.toBe("include"); - expect(fetchMock.mock.calls[2]?.[1]?.headers["content-type"]).toBe("application/json"); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[2]?.[1]?.body))).toMatchObject({ - proof: validProof(), - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }); - - expect(String(fetchMock.mock.calls[3]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/relay-config", - ); - expect(fetchMock.mock.calls[3]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - headers: expect.objectContaining({ - "content-type": "application/json", - }), - }); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - endpointRuntime: { - providerKind: "cloudflare_tunnel", - connectorToken: "connector-token", - tunnelId: "tunnel-id", - tunnelName: "tunnel-name", - }, - }); - }), - ); - - it.effect("reads the primary local cloud link state with the owner cookie session", () => - Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi.fn().mockResolvedValueOnce( + const fetchMock = vi.fn().mockResolvedValue( Response.json({ linked: true, - cloudUserId: "user_123", + cloudUserId: "user-1", relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", + relayIssuer: "https://relay.example.test", publishAgentActivity: false, }), ); vi.stubGlobal("fetch", fetchMock); - const state = yield* withCloudServices(readPrimaryCloudLinkState()); - expect(state).toEqual({ - linked: true, - cloudUserId: "user_123", - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - publishAgentActivity: false, - }); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe( - "http://127.0.0.1:3000/api/connect/link-state", - ); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "GET", - credentials: "include", - }); - }), - ); - - it.effect("clears local relay credentials before revoking the primary cloud link", () => - Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ) - .mockResolvedValueOnce(Response.json({ ok: true })); - vi.stubGlobal("fetch", fetchMock); + const state = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })); - yield* withCloudServices( - unlinkPrimaryEnvironmentFromCloud({ - clerkToken: "clerk-token", + expect(Option.fromNullishOr(state)).toEqual( + Option.some({ + linked: true, + cloudUserId: "user-1", + relayUrl: "https://relay.example.test", + relayIssuer: "https://relay.example.test", + publishAgentActivity: false, }), ); - - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - }); - expect(String(fetchMock.mock.calls[1]?.[0])).toBe( - "https://relay.example.test/v1/client/environment-links/env-1", + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/link-state", ); - expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); - expect(fetchMock.mock.calls[1]?.[1]?.headers.authorization).toBe("Bearer clerk-token"); }), ); - it.effect("still clears local relay credentials when relay revocation fails", () => + it.effect("links an available primary environment without invoking installation", () => Effect.gen(function* () { - vi.mocked(readPrimaryEnvironmentDescriptor).mockReturnValue({ - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }); - vi.mocked(readPrimaryEnvironmentTarget).mockReturnValue({ - source: "desktop-managed", - target: { - httpBaseUrl: "http://127.0.0.1:3000", - wsBaseUrl: "ws://127.0.0.1:3000", - }, - }); const fetchMock = vi .fn() .mockResolvedValueOnce( - Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), + Response.json({ + challenge: "challenge", + expiresAt: "2026-06-06T00:05:00.000Z", + }), ) - .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - unlinkPrimaryEnvironmentFromCloud({ - clerkToken: "clerk-token", - }), - ); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); - expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ - method: "POST", - credentials: "include", - }); - }), - ); - - it.effect("rejects primary environment linking when the local environment is not ready", () => - Effect.gen(function* () { - vi.stubGlobal("fetch", vi.fn()); - - const error = yield* withCloudServices( - linkPrimaryEnvironmentToCloud({ - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Local environment is not ready yet.", - }); - expect(fetch).not.toHaveBeenCalled(); - }), - ); - - it.effect("preserves relay transport failures while linking environments", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce(Response.json({ error: "unavailable" }, { status: 503 })), - ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/client/environment-links failed", - }); - }), - ); - - it.effect("preserves typed relay error bodies while linking environments", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json( - { - _tag: "RelayEnvironmentLinkProofInvalidError", - code: "environment_link_proof_invalid", - reason: "origin_not_allowed", - traceId: "trace-test", - }, - { status: 400 }, - ), - ), - ); - - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, - clerkToken: "clerk-token", - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", - }); - }), - ); - - it.effect("rejects relay credentials for a different environment", () => - Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) + .mockResolvedValueOnce(Response.json("signed-proof")) .mockResolvedValueOnce( Response.json({ ok: true, - environmentId: "env-2", + environmentId: TARGET.environmentId, endpoint: { httpBaseUrl: "https://desktop.example.test", wsBaseUrl: "wss://desktop.example.test", providerKind: "cloudflare_tunnel", }, endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", + relayIssuer: "https://relay.example.test", + cloudUserId: "user-1", + environmentCredential: "environment-credential", + cloudMintPublicKey: "public-key", }), + ) + .mockResolvedValueOnce( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), ); vi.stubGlobal("fetch", fetchMock); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: TARGET, clerkToken: "clerk-token", }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", + ); + + expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + "http://127.0.0.1:3000/api/connect/link-proof", + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(bodyText(fetchMock.mock.calls[1]?.[1]?.body))).toMatchObject({ + challenge: "challenge", + endpoint: { + httpBaseUrl: TARGET.httpBaseUrl, + wsBaseUrl: TARGET.wsBaseUrl, + }, }); - expect(fetchMock).toHaveBeenCalledTimes(3); }), ); - it.effect("rejects relay credentials for a different managed endpoint provider", () => + it.effect("installs a missing relay client before linking", () => Effect.gen(function* () { - const fetchMock = vi - .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "manual", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ); - vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true }))); - const error = yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + linkPrimaryEnvironmentToCloud({ + target: TARGET, clerkToken: "clerk-token", }), + { + status: { status: "available", version: "2026.6.0" }, + installEvents: [], + }, ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint provider.", - }); - expect(fetchMock).toHaveBeenCalledTimes(3); + + expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled(); }), ); - it.effect("passes the relay issuer from the link response into local relay config", () => + it.effect("unlinks locally before revoking the relay record", () => Effect.gen(function* () { const fetchMock = vi .fn() - .mockResolvedValueOnce(Response.json(validChallenge())) - .mockResolvedValueOnce(Response.json(validProof())) - .mockResolvedValueOnce( - Response.json({ - ok: true, - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test", - wsBaseUrl: "wss://desktop.example.test", - providerKind: "cloudflare_tunnel", - }, - endpointRuntime: null, - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - environmentCredential: "t3env_test_credential", - cloudMintPublicKey: "cloud-mint-public-key", - }), - ) .mockResolvedValueOnce( Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }), - ); + ) + .mockResolvedValueOnce(Response.json({ ok: true })); vi.stubGlobal("fetch", fetchMock); - yield* withCloudServices( - linkEnvironmentToCloud({ - environment: savedEnvironment, + yield* withServices( + unlinkPrimaryEnvironmentFromCloud({ + target: TARGET, clerkToken: "clerk-token", }), ); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(requestBodyText(fetchMock.mock.calls[3]?.[1]?.body))).toMatchObject({ - relayUrl: "https://relay.example.test", - relayIssuer: "https://issuer.example.test", - cloudUserId: "user_123", - }); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://127.0.0.1:3000/api/connect/unlink"); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain( + `/v1/client/environment-links/${TARGET.environmentId}`, + ); }), ); }); diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index 4c94ab41660..360ef6d3626 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -1,7 +1,9 @@ import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpTraceContext, type Headers } from "effect/unstable/http"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, type EnvironmentCloudLinkStateResult, @@ -11,36 +13,23 @@ import { EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, EnvironmentId, + WS_METHODS, } from "@t3tools/contracts"; import { - RelayEnvironmentConnectScope, type RelayClientDeviceRecord, - type RelayEnvironmentLinkResponse, - RelayProtectedError, type RelayClientEnvironmentRecord, + type RelayEnvironmentLinkResponse, type RelayProtectedError as RelayProtectedErrorType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; -import { - exchangeRemoteDpopAccessToken, - fetchRemoteEnvironmentDescriptor, - makeEnvironmentHttpApiClient, - ManagedRelayClient, - ManagedRelayDpopSigner, - type WsRpcClient, -} from "@t3tools/client-runtime"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; +import { request, runStream } from "@t3tools/client-runtime/rpc"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import { ManagedRelayClient, type ManagedRelayClientError } from "@t3tools/client-runtime/relay"; -import { ensureLocalApi } from "../localApi"; -import { - getPrimaryEnvironmentConnection, - readEnvironmentConnection, - type SavedEnvironmentRecord, -} from "../environments/runtime"; import { readPrimaryEnvironmentDescriptor, readPrimaryEnvironmentTarget, - resolvePrimaryEnvironmentHttpUrl, } from "../environments/primary"; import { withPrimaryEnvironmentRequestInit } from "../environments/primary/requestInit"; import { resolveCloudPublicConfig } from "./publicConfig"; @@ -65,6 +54,7 @@ function relayUrl(): string | null { export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ readonly message: string; readonly cause?: unknown; + readonly traceId?: string; }> {} const relayClientRpcError = (message: string) => (cause: unknown) => @@ -74,13 +64,13 @@ const relayClientRpcError = (message: string) => (cause: unknown) => }); function ensureRelayClientAvailable( - client: WsRpcClient, -): Effect.Effect { + environmentId: EnvironmentId, +): Effect.Effect { return Effect.gen(function* () { - const status = yield* Effect.tryPromise({ - try: () => client.cloud.getRelayClientStatus(), - catch: relayClientRpcError("Could not check relay client availability."), - }); + const registry = yield* EnvironmentRegistry; + const status = yield* registry + .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {})) + .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability."))); if (status.status === "available") return; if (status.status === "unsupported") { return yield* new CloudEnvironmentLinkError({ @@ -98,22 +88,35 @@ function ensureRelayClientAvailable( }); } - const installed = yield* Effect.tryPromise({ - try: () => client.cloud.installRelayClient(reportRelayClientInstallProgress), - catch: relayClientRpcError("Could not install the relay client."), - }).pipe(Effect.ensuring(Effect.sync(finishRelayClientInstall))); - if (installed.status !== "available") { + const installed = yield* registry + .runStream( + environmentId, + runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( + Stream.tap((event) => Effect.sync(() => reportRelayClientInstallProgress(event))), + ), + ) + .pipe( + Stream.runLast, + Effect.mapError(relayClientRpcError("Could not install the relay client.")), + Effect.ensuring(Effect.sync(finishRelayClientInstall)), + ); + if (Option.isNone(installed) || installed.value.type !== "complete") { + return yield* new CloudEnvironmentLinkError({ + message: "The relay client install completed without a final status.", + }); + } + const installedStatus = installed.value.status; + if (installedStatus.status !== "available") { return yield* new CloudEnvironmentLinkError({ message: - installed.status === "unsupported" - ? `T3 Code cannot install the relay client automatically on ${installed.platform}-${installed.arch}.` + installedStatus.status === "unsupported" + ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.` : "The relay client is still unavailable after installation.", }); } }); } -const isRelayProtectedError = Schema.is(RelayProtectedError); const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -156,31 +159,22 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayAgentActivityPublishProofInvalidError": return `Relay rejected the agent activity publish proof (${error.reason}).`; case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}, trace ${error.traceId}).`; + return `Relay encountered an internal error (${error.reason}).`; } } function decodedRelayClientError(message: string) { - return (cause: unknown) => { - const relayError = findRelayProtectedError(cause); + return (cause: ManagedRelayClientError) => { + const relayError = cause.relayError; const detail = relayError ? relayProtectedErrorMessage(relayError) : null; return new CloudEnvironmentLinkError({ message: detail ? `${message}: ${detail}` : message, cause, + ...(cause.traceId ? { traceId: cause.traceId } : {}), }); }; } -function findRelayProtectedError(cause: unknown): RelayProtectedErrorType | null { - if (isRelayProtectedError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findRelayProtectedError(cause.cause) : null; -} - function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { if (isEnvironmentCloudApiError(cause)) { return cause; @@ -239,16 +233,6 @@ export interface CloudLinkTarget { export type CloudLinkState = EnvironmentCloudLinkStateResult; -export interface CloudManagedConnection { - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly label: string; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly relayUrl: string; - readonly accessToken: string; - readonly relayTraceHeaders: Headers.Headers; -} - export function collectCloudLinkTargets(input: { readonly primary: CloudLinkTarget | null; readonly saved: ReadonlyArray; @@ -336,130 +320,11 @@ export function listCloudDevices(input: { }); } -export function connectManagedCloudEnvironment(input: { - readonly clerkToken: string; - readonly environment: RelayClientEnvironmentRecord; - readonly relayUrl?: string; -}): Effect.Effect< - CloudManagedConnection, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelayClient | ManagedRelayDpopSigner -> { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl); - if (persistedRelayUrl && persistedRelayUrl !== configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "The saved environment is linked through a different configured relay.", - }); - } - const relayClient = yield* ManagedRelayClient; - const connected = yield* relayClient - .connectEnvironment({ - clerkToken: input.clerkToken, - scopes: [RelayEnvironmentConnectScope], - environmentId: input.environment.environmentId, - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not connect to relay-managed environment.", - cause, - }), - ), - ); - if (connected.environmentId !== input.environment.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", - }); - } - if ( - connected.endpoint.httpBaseUrl !== input.environment.endpoint.httpBaseUrl || - connected.endpoint.wsBaseUrl !== input.environment.endpoint.wsBaseUrl || - connected.endpoint.providerKind !== input.environment.endpoint.providerKind - ) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint.", - }); - } - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not read connected environment descriptor.", - cause, - }), - ), - ); - if (descriptor.environmentId !== connected.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Connected endpoint does not match the selected environment.", - }); - } - const signer = yield* ManagedRelayDpopSigner; - const bootstrapProof = yield* signer - .createProof({ - method: "POST", - url: new URL("/oauth/token", connected.endpoint.httpBaseUrl).toString(), - }) - .pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not create environment DPoP proof.", - cause, - }), - ), - ); - const session = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: connected.endpoint.httpBaseUrl, - credential: connected.credential, - dpopProof: bootstrapProof, - }).pipe( - Effect.mapError( - (cause) => - new CloudEnvironmentLinkError({ - message: "Could not authorize managed environment.", - cause, - }), - ), - ); - return { - environmentId: descriptor.environmentId, - label: descriptor.label, - httpBaseUrl: connected.endpoint.httpBaseUrl, - 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< - CloudLinkState | null, - CloudEnvironmentLinkError, - HttpClient.HttpClient -> { +export function readPrimaryCloudLinkState(input: { + readonly target: CloudLinkTarget; +}): Effect.Effect { return Effect.gen(function* () { - if (!readPrimaryCloudLinkTarget()) { - return null; - } - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .linkState({ headers: {} }) .pipe( @@ -470,10 +335,11 @@ export function readPrimaryCloudLinkState(): Effect.Effect< } export function updatePrimaryCloudPreferences(input: { + readonly target: CloudLinkTarget; readonly publishAgentActivity: boolean; }): Effect.Effect { return Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); return yield* client.connect .preferences({ headers: {}, @@ -487,16 +353,11 @@ export function updatePrimaryCloudPreferences(input: { } export function unlinkPrimaryEnvironmentFromCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string | null; }): Effect.Effect { return Effect.gen(function* () { - const target = readPrimaryCloudLinkTarget(); - if (!target) { - return yield* new CloudEnvironmentLinkError({ - message: "Local environment is not ready yet.", - }); - } - const client = yield* makeEnvironmentHttpApiClient(resolvePrimaryEnvironmentHttpUrl("/")); + const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); yield* client.connect .unlink({ headers: {} }) .pipe( @@ -510,7 +371,7 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { yield* relayClient .unlinkEnvironment({ clerkToken: input.clerkToken, - environmentId: EnvironmentId.make(target.environmentId), + environmentId: EnvironmentId.make(input.target.environmentId), }) .pipe( Effect.catch((cause) => @@ -523,115 +384,14 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { }); } -export function linkEnvironmentToCloud(input: { - readonly environment: SavedEnvironmentRecord; - readonly clerkToken: string; -}): Effect.Effect { - return Effect.gen(function* () { - const configuredRelayUrl = relayUrl(); - if (!configuredRelayUrl) { - return yield* new CloudEnvironmentLinkError({ - message: "T3CODE_RELAY_URL is not configured.", - }); - } - const relayClient = yield* ManagedRelayClient; - const bearerToken = yield* Effect.tryPromise({ - try: () => - ensureLocalApi().persistence.getSavedEnvironmentSecret(input.environment.environmentId), - catch: (cause) => - new CloudEnvironmentLinkError({ - message: `Could not read saved bearer token for ${input.environment.label}.`, - cause, - }), - }); - if (!bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: `No saved bearer token for ${input.environment.label}.`, - }); - } - - const connection = readEnvironmentConnection(input.environment.environmentId); - if (!connection) { - return yield* new CloudEnvironmentLinkError({ - message: `${input.environment.label} is not connected.`, - }); - } - yield* ensureRelayClientAvailable(connection.client); - - const environmentClient = yield* makeEnvironmentHttpApiClient(input.environment.httpBaseUrl); - const headers = { authorization: `Bearer ${bearerToken}` }; - - const challenge = yield* relayClient - .createEnvironmentLinkChallenge({ - clerkToken: input.clerkToken, - payload: { - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError( - `${configuredRelayUrl}/v1/client/environment-link-challenges failed`, - ), - ), - ); - const proof = yield* environmentClient.connect - .linkProof({ - headers, - payload: { - challenge: challenge.challenge, - relayIssuer: configuredRelayUrl, - endpoint: { - httpBaseUrl: input.environment.httpBaseUrl, - wsBaseUrl: input.environment.wsBaseUrl, - providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, - }, - origin: endpointOrigin(input.environment.httpBaseUrl), - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof."))); - const link = yield* relayClient - .linkEnvironment({ - clerkToken: input.clerkToken, - payload: { - proof, - notificationsEnabled: true, - liveActivitiesEnabled: true, - managedTunnelsEnabled: true, - }, - }) - .pipe( - Effect.mapError( - decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`), - ), - ); - yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: input.environment.environmentId, - expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, - link, - }); - - yield* environmentClient.connect - .relayConfig({ - headers, - payload: { - relayUrl: configuredRelayUrl, - relayIssuer: link.relayIssuer, - cloudUserId: link.cloudUserId, - environmentCredential: link.environmentCredential, - cloudMintPublicKey: link.cloudMintPublicKey, - endpointRuntime: link.endpointRuntime, - }, - }) - .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access."))); - }); -} - export function linkPrimaryEnvironmentToCloud(input: { + readonly target: CloudLinkTarget; readonly clerkToken: string; -}): Effect.Effect { +}): Effect.Effect< + void, + CloudEnvironmentLinkError, + EnvironmentRegistry | HttpClient.HttpClient | ManagedRelayClient +> { return Effect.gen(function* () { const configuredRelayUrl = relayUrl(); if (!configuredRelayUrl) { @@ -640,14 +400,8 @@ export function linkPrimaryEnvironmentToCloud(input: { }); } const relayClient = yield* ManagedRelayClient; - const target = readPrimaryCloudLinkTarget(); - if (!target) { - return yield* new CloudEnvironmentLinkError({ - message: "Local environment is not ready yet.", - }); - } - const environmentClient = yield* makeEnvironmentHttpApiClient(target.httpBaseUrl); - yield* ensureRelayClientAvailable(getPrimaryEnvironmentConnection().client); + const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl); + yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId)); const challenge = yield* relayClient .createEnvironmentLinkChallenge({ @@ -672,11 +426,11 @@ export function linkPrimaryEnvironmentToCloud(input: { challenge: challenge.challenge, relayIssuer: configuredRelayUrl, endpoint: { - httpBaseUrl: target.httpBaseUrl, - wsBaseUrl: target.wsBaseUrl, + httpBaseUrl: input.target.httpBaseUrl, + wsBaseUrl: input.target.wsBaseUrl, providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, - origin: endpointOrigin(target.httpBaseUrl), + origin: endpointOrigin(input.target.httpBaseUrl), }, }) .pipe( @@ -699,7 +453,7 @@ export function linkPrimaryEnvironmentToCloud(input: { ), ); yield* ensureLinkedEnvironmentMatches({ - expectedEnvironmentId: target.environmentId, + expectedEnvironmentId: input.target.environmentId, expectedProviderKind: MANAGED_ENDPOINT_PROVIDER_KIND, link, }); diff --git a/apps/web/src/cloud/linkEnvironmentAtoms.ts b/apps/web/src/cloud/linkEnvironmentAtoms.ts new file mode 100644 index 00000000000..9f28ccdc077 --- /dev/null +++ b/apps/web/src/cloud/linkEnvironmentAtoms.ts @@ -0,0 +1,22 @@ +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { + linkPrimaryEnvironmentToCloud, + type CloudLinkTarget, + unlinkPrimaryEnvironmentFromCloud, +} from "./linkEnvironment"; + +export const linkPrimaryEnvironment = connectionAtomRuntime + .fn<{ + readonly target: CloudLinkTarget; + readonly clerkToken: string; + }>()(linkPrimaryEnvironmentToCloud) + .pipe(Atom.withLabel("web:cloud:link-primary-environment")); + +export const unlinkPrimaryEnvironment = connectionAtomRuntime + .fn<{ + readonly target: CloudLinkTarget; + readonly clerkToken: string | null; + }>()(unlinkPrimaryEnvironmentFromCloud) + .pipe(Atom.withLabel("web:cloud:unlink-primary-environment")); diff --git a/apps/web/src/cloud/managedAuth.tsx b/apps/web/src/cloud/managedAuth.tsx index b00c445f08d..52e7a067e67 100644 --- a/apps/web/src/cloud/managedAuth.tsx +++ b/apps/web/src/cloud/managedAuth.tsx @@ -1,7 +1,14 @@ import { useAuth } from "@clerk/react"; -import { createManagedRelaySession, setManagedRelaySession } from "@t3tools/client-runtime"; -import { useEffect, type ReactNode } from "react"; +import { + createManagedRelaySession, + ManagedRelayClient, + setManagedRelaySession, +} from "@t3tools/client-runtime/relay"; +import * as Effect from "effect/Effect"; +import { useEffect, useRef, type ReactNode } from "react"; +import { useEnvironmentConnectionActions } from "../state/environments"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { resolveRelayClerkTokenOptions } from "./publicConfig"; @@ -12,24 +19,80 @@ export async function readManagedRelayClerkToken(): Promise { } export function ManagedRelayAuthProvider({ children }: { readonly children: ReactNode }) { - const { getToken, isSignedIn, userId } = useAuth(); + const { getToken, isLoaded, isSignedIn, userId } = useAuth({ + treatPendingAsSignedOut: false, + }); + const { removeRelayEnvironments } = useEnvironmentConnectionActions(); + const observedAccountRef = useRef(undefined); + const accountTransitionRef = useRef(Promise.resolve()); useEffect(() => { + if (!isLoaded) { + return; + } + + let cancelled = false; + const previousAccount = observedAccountRef.current; + const nextAccount = isSignedIn && userId ? userId : null; + observedAccountRef.current = nextAccount; + + const queueAccountCleanup = () => { + accountTransitionRef.current = accountTransitionRef.current.then(async () => { + const results = await Promise.allSettled([ + removeRelayEnvironments(), + runtime.runPromise( + ManagedRelayClient.pipe(Effect.flatMap((client) => client.resetTokenCache)), + ), + ]); + for (const result of results) { + if (result.status === "rejected") { + console.warn("[t3-cloud] cloud account cleanup failed", result.reason); + } + } + }); + return accountTransitionRef.current; + }; + relayTokenProvider = isSignedIn ? () => getToken(resolveRelayClerkTokenOptions()) : null; - setManagedRelaySession( - appAtomRegistry, - isSignedIn && userId - ? createManagedRelaySession({ - accountId: userId, - readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), - }) - : null, - ); + if (!isSignedIn || !userId) { + setManagedRelaySession(appAtomRegistry, null); + if (previousAccount !== null) { + void queueAccountCleanup(); + } + } else { + if (previousAccount !== undefined && previousAccount !== null && previousAccount !== userId) { + setManagedRelaySession(appAtomRegistry, null); + void queueAccountCleanup().then(() => { + if (!cancelled) { + setManagedRelaySession( + appAtomRegistry, + createManagedRelaySession({ + accountId: userId, + readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), + }), + ); + } + }); + } else { + void accountTransitionRef.current.then(() => { + if (!cancelled) { + setManagedRelaySession( + appAtomRegistry, + createManagedRelaySession({ + accountId: userId, + readClerkToken: () => getToken(resolveRelayClerkTokenOptions()), + }), + ); + } + }); + } + } return () => { + cancelled = true; relayTokenProvider = null; setManagedRelaySession(appAtomRegistry, null); }; - }, [getToken, isSignedIn, userId]); + }, [getToken, isLoaded, isSignedIn, removeRelayEnvironments, userId]); return children; } diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index f34ad2f9c99..53a3e24c6d8 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -1,8 +1,8 @@ import { - managedRelayClientLayer, + managedRelayClientLayer as makeManagedRelayClientLayer, ManagedRelayDpopSigner, ManagedRelayDpopSignerError, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import { RelayWebClientId } from "@t3tools/contracts/relay"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -17,7 +17,7 @@ import { type BrowserDpopKey, } from "./dpop"; -export const webRelayDpopSignerLayer = Layer.effect( +export const relayDpopSignerLayer = Layer.effect( ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -39,24 +39,28 @@ export const webRelayDpopSignerLayer = Layer.effect( return generated; }), ); - const signerError = (cause: unknown) => new ManagedRelayDpopSignerError({ cause }); + return ManagedRelayDpopSigner.of({ thumbprint: loadOrCreateBrowserDpopKey.pipe( Effect.map((proofKey) => proofKey.thumbprint), - Effect.mapError(signerError), + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + Effect.withSpan("web.managedRelayDpopSigner.loadThumbprint"), + ), + createProof: Effect.fn("web.managedRelayDpopSigner.createProof")( + function* (input) { + const proofKey = yield* loadOrCreateBrowserDpopKey; + return yield* createBrowserDpopProof({ ...input, proofKey }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((proof) => proof.proof), + ); + }, + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), ), - createProof: (input) => - loadOrCreateBrowserDpopKey.pipe( - Effect.flatMap((proofKey) => createBrowserDpopProof({ ...input, proofKey })), - Effect.provideService(Crypto.Crypto, crypto), - Effect.map((proof) => proof.proof), - Effect.mapError(signerError), - ), }); }), ); -export const webManagedRelayClientLayer = (relayUrl: string) => - managedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( - Layer.provideMerge(webRelayDpopSignerLayer), +export const managedRelayClientLayer = (relayUrl: string) => + makeManagedRelayClientLayer({ relayUrl, clientId: RelayWebClientId }).pipe( + Layer.provideMerge(relayDpopSignerLayer), ); diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index a31ee9e16f3..0a1ec61a3cc 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -4,7 +4,7 @@ import { ManagedRelayClient, managedRelaySessionAtom, readManagedRelaySnapshotState, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/relay"; import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, @@ -13,17 +13,15 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; -import { webRuntime } from "../lib/runtime"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; const managedRelayAtomRuntime = Atom.runtime( Layer.effect( ManagedRelayClient, - webRuntime.contextEffect.pipe( - Effect.map((context) => Context.get(context, ManagedRelayClient)), - ), + runtime.contextEffect.pipe(Effect.map((context) => Context.get(context, ManagedRelayClient))), ), ); @@ -44,6 +42,15 @@ export function useManagedRelayEnvironments() { ? managedRelayQueryManager.environmentsAtom(accountId) : EMPTY_ENVIRONMENTS_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay environment listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); @@ -51,7 +58,7 @@ export function useManagedRelayEnvironments() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; @@ -62,6 +69,15 @@ export function useManagedRelayDevices() { const accountId = session?.accountId ?? null; const atom = accountId ? managedRelayQueryManager.devicesAtom(accountId) : EMPTY_DEVICES_ATOM; const result = useAtomValue(atom); + const snapshot = readManagedRelaySnapshotState(result); + useEffect(() => { + if (snapshot.error) { + console.error("[t3-cloud] Relay device listing failed", { + message: snapshot.error, + traceId: snapshot.errorTraceId, + }); + } + }, [snapshot.error, snapshot.errorTraceId]); const refresh = useCallback(() => { if (accountId) { managedRelayQueryManager.refreshDevices(appAtomRegistry, accountId); @@ -69,7 +85,7 @@ export function useManagedRelayDevices() { }, [accountId]); return { - ...readManagedRelaySnapshotState(result), + ...snapshot, accountId, refresh, }; diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 095ca842281..34fdacd214a 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import type { EnvironmentCloudLinkStateResult, EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentCloudLinkStateResult } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -7,51 +7,68 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { HttpClient } from "effect/unstable/http"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; -import { usePrimaryEnvironmentId } from "../environments/primary"; -import { webRuntime } from "../lib/runtime"; +import { usePrimaryEnvironment } from "../state/environments"; +import { runtime } from "../lib/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { readPrimaryCloudLinkState } from "./linkEnvironment"; +import { readPrimaryCloudLinkState, type CloudLinkTarget } from "./linkEnvironment"; const primaryCloudLinkAtomRuntime = Atom.runtime( Layer.effect( HttpClient.HttpClient, - webRuntime.contextEffect.pipe( + runtime.contextEffect.pipe( Effect.map((context) => Context.get(context, HttpClient.HttpClient)), ), ), ); -const primaryCloudLinkStateAtom = Atom.family((environmentId: EnvironmentId) => - primaryCloudLinkAtomRuntime - .atom(readPrimaryCloudLinkState()) +const primaryCloudLinkStateAtom = Atom.family((key: string) => { + const target = JSON.parse(key) as CloudLinkTarget; + return primaryCloudLinkAtomRuntime + .atom(readPrimaryCloudLinkState({ target })) .pipe( Atom.swr({ staleTime: 5_000, revalidateOnMount: true }), Atom.setIdleTTL(5 * 60_000), - Atom.withLabel(`primary-cloud-link:${environmentId}`), - ), -); + Atom.withLabel(`primary-cloud-link:${target.environmentId}`), + ); +}); const EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM = Atom.make( AsyncResult.success(null), ).pipe(Atom.keepAlive, Atom.withLabel("primary-cloud-link:null")); -export function refreshPrimaryCloudLinkState(environmentId: EnvironmentId | null): void { - if (environmentId) { - appAtomRegistry.refresh(primaryCloudLinkStateAtom(environmentId)); +function targetKey(target: CloudLinkTarget): string { + return JSON.stringify(target); +} + +export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { + if (target) { + appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } } export function usePrimaryCloudLinkState() { - const environmentId = usePrimaryEnvironmentId(); - const atom = environmentId - ? primaryCloudLinkStateAtom(environmentId) + const primary = usePrimaryEnvironment(); + const target = useMemo( + () => + primary?.entry.target._tag === "PrimaryConnectionTarget" + ? { + environmentId: primary.environmentId, + label: primary.label, + httpBaseUrl: primary.entry.target.httpBaseUrl, + wsBaseUrl: primary.entry.target.wsBaseUrl, + } + : null, + [primary], + ); + const atom = target + ? primaryCloudLinkStateAtom(targetKey(target)) : EMPTY_PRIMARY_CLOUD_LINK_STATE_ATOM; const result = useAtomValue(atom); const refresh = useCallback(() => { - refreshPrimaryCloudLinkState(environmentId); - }, [environmentId]); + refreshPrimaryCloudLinkState(target); + }, [target]); let error: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); @@ -63,5 +80,6 @@ export function usePrimaryCloudLinkState() { error, isPending: result.waiting, refresh, + target, }; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 27c5c311c60..e135d0813b8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -1,4 +1,4 @@ -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -11,9 +11,8 @@ import { import { memo, useMemo } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { useProject, useThreadDetail } from "../state/entities"; import { useIsMobile } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { type EnvMode, type EnvironmentOption, @@ -207,8 +206,7 @@ export const BranchToolbar = memo(function BranchToolbar({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); - const serverThread = useStore(serverThreadSelector); + const serverThread = useThreadDetail(threadRef); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); @@ -217,21 +215,17 @@ export const BranchToolbar = memo(function BranchToolbar({ : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const activeProjectSelector = useMemo( - () => createProjectSelectorByRef(activeProjectRef), - [activeProjectRef], - ); - const activeProject = useStore(activeProjectSelector); - const hasActiveThread = serverThread !== undefined || draftThread !== null; + const activeProject = useProject(activeProjectRef); + const hasActiveThread = serverThread !== null || draftThread !== null; const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ activeWorktreePath, - hasServerThread: serverThread !== undefined, + hasServerThread: serverThread !== null, draftThreadEnvMode: draftThread?.envMode, }); - const envModeLocked = envLocked || (serverThread !== undefined && activeWorktreePath !== null); + const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); const showEnvironmentPicker = Boolean( availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 72391f714fc..245415ba03c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,6 @@ -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { @@ -15,15 +16,14 @@ import { } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; -import { readEnvironmentApi } from "../environmentApi"; -import { useVcsStatus } from "../lib/vcsStatusState"; -import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState"; -import { newCommandId } from "../lib/utils"; +import { usePaginatedBranches } from "../state/queries"; +import { useProject, useThreadDetail } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; -import { useStore } from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchSelectionTarget, @@ -58,8 +58,6 @@ interface BranchToolbarBranchSelectorProps { onComposerFocusRequest?: () => void; } -const EMPTY_REFS: ReadonlyArray = []; - function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -91,6 +89,12 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const stopThreadSession = useAtomSet(threadEnvironment.stopSession, { mode: "promise" }); + const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, { + mode: "promise", + }); + const switchRef = useAtomSet(vcsEnvironment.switchRef, { mode: "promise" }); + const createRefMutation = useAtomSet(vcsEnvironment.createRef, { mode: "promise" }); // --------------------------------------------------------------------------- // Thread / project state (pushed down from parent to colocate with mutation) // --------------------------------------------------------------------------- @@ -98,10 +102,8 @@ export function BranchToolbarBranchSelector({ () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); - const serverThreadSelector = useMemo(() => createThreadSelectorByRef(threadRef), [threadRef]); - const serverThread = useStore(serverThreadSelector); + const serverThread = useThreadDetail(threadRef); const serverSession = serverThread?.session ?? null; - const setThreadBranchAction = useStore((store) => store.setThreadBranch); const draftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) : store.getDraftThreadByRef(threadRef), ); @@ -112,11 +114,7 @@ export function BranchToolbarBranchSelector({ : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const activeProjectSelector = useMemo( - () => createProjectSelectorByRef(activeProjectRef), - [activeProjectRef], - ); - const activeProject = useStore(activeProjectSelector); + const activeProject = useProject(activeProjectRef); const activeThreadId = serverThread?.id ?? (draftThread ? threadId : undefined); const activeThreadBranch = @@ -124,9 +122,9 @@ export function BranchToolbarBranchSelector({ ? activeThreadBranchOverride : (serverThread?.branch ?? draftThread?.branch ?? null); const activeWorktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; - const activeProjectCwd = activeProject?.cwd ?? null; + const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; - const hasServerThread = serverThread !== undefined; + const hasServerThread = serverThread !== null; const effectiveEnvMode = effectiveEnvModeOverride ?? resolveEffectiveEnvMode({ @@ -141,29 +139,24 @@ export function BranchToolbarBranchSelector({ const setThreadBranch = useCallback( (branch: string | null, worktreePath: string | null) => { if (!activeThreadId || !activeProject) return; - const api = readEnvironmentApi(environmentId); - if (serverSession && worktreePath !== activeWorktreePath && api) { - void api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: activeThreadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); + if (serverSession && worktreePath !== activeWorktreePath) { + void stopThreadSession({ + environmentId, + input: { threadId: activeThreadId }, + }).catch(() => undefined); } - if (api && hasServerThread) { - void api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadId, - branch, - worktreePath, + if (hasServerThread) { + void updateThreadMetadata({ + environmentId, + input: { + threadId: activeThreadId, + branch, + worktreePath, + }, }); } if (hasServerThread) { onActiveThreadBranchOverrideChange?.(branch); - setThreadBranchAction(threadRef, branch, worktreePath); return; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ @@ -185,12 +178,13 @@ export function BranchToolbarBranchSelector({ activeWorktreePath, hasServerThread, onActiveThreadBranchOverrideChange, - setThreadBranchAction, setDraftThreadContext, draftId, threadRef, environmentId, effectiveEnvMode, + stopThreadSession, + updateThreadMetadata, ], ); @@ -201,7 +195,14 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); + const branchStatusQuery = useEnvironmentQuery( + branchCwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd: branchCwd }, + }), + ); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const branchRefTarget = useMemo( @@ -212,11 +213,11 @@ export function BranchToolbarBranchSelector({ }), [branchCwd, deferredTrimmedBranchQuery, environmentId], ); - const branchRefState = useVcsRefs(branchRefTarget); - const refs = branchRefState.data?.refs ?? EMPTY_REFS; + const branchRefState = usePaginatedBranches(branchRefTarget); + const refs = branchRefState.refs; const hasNextPage = branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; - const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); + const isFetchingNextPage = branchRefState.isPending && branchRefState.data !== null; const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; @@ -296,15 +297,13 @@ export function BranchToolbarBranchSelector({ const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); - await vcsRefManager - .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) - .catch(() => undefined); + branchRefState.refresh(); + branchStatusQuery.refresh(); }); }; const selectBranch = (refName: VcsRef) => { - const api = readEnvironmentApi(environmentId); - if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; + if (!branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { setThreadBranch(refName.name, null); @@ -337,9 +336,12 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - const checkoutResult = await api.vcs.switchRef({ - cwd: selectionTarget.checkoutCwd, - refName: refName.name, + const checkoutResult = await switchRef({ + environmentId, + input: { + cwd: selectionTarget.checkoutCwd, + refName: refName.name, + }, }); const nextBranchName = refName.isRemote ? (checkoutResult.refName ?? selectedBranchName) @@ -361,8 +363,7 @@ export function BranchToolbarBranchSelector({ const createRef = (rawName: string) => { const name = rawName.trim(); - const api = readEnvironmentApi(environmentId); - if (!api || !branchCwd || !name || isBranchActionPending) return; + if (!branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); @@ -371,10 +372,13 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); try { - const createBranchResult = await api.vcs.createRef({ - cwd: branchCwd, - refName: name, - switchRef: true, + const createBranchResult = await createRefMutation({ + environmentId, + input: { + cwd: branchCwd, + refName: name, + switchRef: true, + }, }); setOptimisticBranch(createBranchResult.refName); setThreadBranch(createBranchResult.refName, activeWorktreePath); @@ -413,11 +417,9 @@ export function BranchToolbarBranchSelector({ setBranchQuery(""); return; } - void vcsRefManager - .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true }) - .catch(() => undefined); + branchRefState.refresh(); }, - [branchRefTarget], + [branchRefState.refresh], ); const branchListScrollElementRef = useRef(null); @@ -428,12 +430,8 @@ export function BranchToolbarBranchSelector({ return; } - setIsFetchingNextPage(true); - void vcsRefManager - .loadNext(branchRefTarget, undefined, { limit: 100 }) - .catch(() => undefined) - .finally(() => setIsFetchingNextPage(false)); - }, [branchRefTarget, hasNextPage, isFetchingNextPage]); + branchRefState.loadNext(); + }, [branchRefState.loadNext, hasNextPage, isFetchingNextPage]); const maybeFetchNextBranchPage = useCallback(() => { if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { return; diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index e047392c12d..a93a6d231fd 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,12 +36,32 @@ 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"; + +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(); localStorage.clear(); document.body.innerHTML = ""; @@ -155,6 +188,66 @@ 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("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( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a9b4ae5372b..79d6d73450c 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, @@ -45,7 +45,7 @@ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./ui/collapsi import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; @@ -62,6 +62,19 @@ import { } from "../markdown-links"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; +import { useActiveEnvironmentId } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { assetEnvironment } from "../state/assets"; +import { usePreparedConnection } from "../state/session"; +import { usePreviewActions } from "../state/preview"; +import { useAtomSet } from "@effect/atom-react"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { + isBrowserPreviewFile, + openFileInPreview, + openUrlInPreview, +} from "../browser/openFileInPreview"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -87,6 +100,7 @@ class CodeHighlightErrorBoundary extends React.Component< interface ChatMarkdownProps { text: string; cwd: string | undefined; + threadRef?: ScopedThreadRef | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -670,6 +684,8 @@ interface MarkdownFileLinkProps { label: string; copyMarkdown: string; theme: "light" | "dark"; + onOpen: (targetPath: string) => Promise; + onOpenInBrowser?: (() => Promise) | undefined; className?: string | undefined; } @@ -944,19 +960,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ label, copyMarkdown, theme, + onOpen, + onOpenInBrowser, className, }: MarkdownFileLinkProps) { const handleOpen = useCallback(() => { - const api = readLocalApi(); - if (!api) { - toastManager.add({ - type: "error", - title: "Open in editor is unavailable", - }); - return; - } - - void openInPreferredEditor(api, targetPath).catch((error) => { + void onOpen(targetPath).catch((error) => { toastManager.add( stackedThreadToast({ type: "error", @@ -965,7 +974,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }), ); }); - }, [targetPath]); + }, [onOpen, targetPath]); const handleCopy = useCallback((value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -1010,6 +1019,9 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const clicked = await api.contextMenu.show( [ { id: "open", label: "Open in editor" }, + ...(onOpenInBrowser + ? ([{ 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, @@ -1020,6 +1032,18 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpen(); return; } + if (clicked === "open-in-browser") { + void onOpenInBrowser?.().catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + return; + } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -1028,7 +1052,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleCopy(targetPath, "Full path"); } }, - [displayPath, handleCopy, handleOpen, targetPath], + [displayPath, handleCopy, handleOpen, onOpenInBrowser, targetPath], ); return ( @@ -1074,6 +1098,8 @@ function areMarkdownFileLinkPropsEqual( previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && previous.theme === next.theme && + previous.onOpen === next.onOpen && + previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); } @@ -1081,12 +1107,24 @@ function areMarkdownFileLinkPropsEqual( function ChatMarkdown({ text, cwd, + threadRef, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const createAssetUrl = useAtomSet(assetEnvironment.createUrl, { mode: "promise" }); + const { open: openPreview } = usePreviewActions(); + const preparedConnection = usePreparedConnection(threadRef?.environmentId ?? null); + const environmentId = useActiveEnvironmentId(); + const serverConfig = useEnvironmentQuery( + environmentId === null ? null : serverEnvironment.config({ environmentId, input: {} }), + ); + const openInPreferredEditor = useOpenInPreferredEditor( + environmentId, + serverConfig.data?.availableEditors ?? [], + ); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -1121,6 +1159,28 @@ function ChatMarkdown({ event.clipboardData.setData("text/plain", payload.text); event.clipboardData.setData("text/html", payload.html); }, []); + const openExternalLinkInPreview = useCallback( + (url: string) => { + if (!threadRef) return Promise.reject(new Error("Thread context is unavailable.")); + return openUrlInPreview({ threadRef, url, openPreview }); + }, + [openPreview, threadRef], + ); + const openMarkdownFileInPreview = useCallback( + (path: string) => { + if (!threadRef || preparedConnection._tag === "None") { + return Promise.reject(new Error("Environment is not connected.")); + } + return openFileInPreview({ + threadRef, + filePath: path, + httpBaseUrl: preparedConnection.value.httpBaseUrl, + createAssetUrl, + openPreview, + }); + }, + [createAssetUrl, openPreview, preparedConnection, threadRef], + ); const markdownComponents = useMemo( () => ({ p({ node: _node, children, ...props }) { @@ -1136,6 +1196,7 @@ function ChatMarkdown({ const faviconHost = resolveExternalLinkHost(href); const isSameDocumentLink = href?.startsWith("#") ?? false; const onClick = props.onClick; + const canOpenInPreview = Boolean(threadRef) && isPreviewSupportedInRuntime(); const link = (
{ + if (!canOpenInPreview || !href) return; + event.preventDefault(); + event.stopPropagation(); + const api = readLocalApi(); + if (!api) return; + void 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 }, + ) + .then((clicked) => { + if (clicked === "open-in-browser") { + return openExternalLinkInPreview(href); + } + if (clicked === "open-external") return api.shell.openExternal(href); + }) + .catch(() => undefined); + }} > {faviconHost ? ( @@ -1194,6 +1277,14 @@ function ChatMarkdown({ label={labelParts.join(" · ")} copyMarkdown={`[${fileLinkMeta.basename}](${normalizedHref})`} theme={resolvedTheme} + onOpen={openInPreferredEditor} + onOpenInBrowser={ + threadRef && + isPreviewSupportedInRuntime() && + isBrowserPreviewFile(fileLinkMeta.filePath) + ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) + : undefined + } className={props.className} /> ); @@ -1238,8 +1329,12 @@ function ChatMarkdown({ fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, + openInPreferredEditor, + openExternalLinkInPreview, + openMarkdownFileInPreview, resolvedTheme, skills, + threadRef, ], ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 5a92a244c52..d3cfee70d19 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -5,7 +5,6 @@ import { EventId, ORCHESTRATION_WS_METHODS, EnvironmentId, - type EnvironmentApi, type MessageId, type OrchestrationReadModel, type ProjectId, @@ -22,7 +21,7 @@ import { DEFAULT_TERMINAL_ID, ServerConfig as ServerConfigSchema, } from "@t3tools/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; import * as Option from "effect/Option"; @@ -44,16 +43,6 @@ import { render } from "vitest-browser-react"; import { useCommandPaletteStore } from "../commandPaletteStore"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; -import { - __resetEnvironmentApiOverridesForTests, - __setEnvironmentApiOverrideForTests, -} from "../environmentApi"; -import { - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, removeInlineTerminalContextPlaceholder, @@ -61,12 +50,10 @@ import { } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetLocalApiForTests } from "../localApi"; -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { getServerConfig } from "../rpc/serverState"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { getRouter } from "../router"; +import { primaryServerConfigAtom } from "../state/server"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; -import { selectBootstrapCompleteForActiveEnvironment, useStore } from "../store"; -import { terminalSessionManager } from "../terminalSessionState"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { useUiStateStore } from "../uiStateStore"; import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; @@ -74,46 +61,12 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -vi.mock("../lib/vcsStatusState", () => { - const status = { - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: true, - refName: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }, - error: null, - cause: null, - isPending: false, - }; - - return { - getVcsStatusSnapshot: () => status, - useVcsStatus: () => status, - useVcsStatuses: () => new Map(), - refreshVcsStatus: () => Promise.resolve(null), - resetVcsStatusStateForTests: () => undefined, - }; -}); - const THREAD_ID = "thread-browser-test" as ThreadId; const THREAD_TITLE = "Browser test thread"; const ARCHIVED_SECONDARY_THREAD_ID = "thread-secondary-project-archived" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const SECOND_PROJECT_ID = "project-2" as ProjectId; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const THREAD_REF = scopeThreadRef(LOCAL_ENVIRONMENT_ID, THREAD_ID); const THREAD_KEY = scopedThreadKey(THREAD_REF); const UUID_ROUTE_RE = /^\/draft\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; @@ -122,7 +75,7 @@ const PROJECT_LOGICAL_KEY = deriveLogicalProjectKeyFromSettings( { environmentId: LOCAL_ENVIRONMENT_ID, id: PROJECT_ID, - cwd: "/repo/project", + workspaceRoot: "/repo/project", repositoryIdentity: null, }, { @@ -134,6 +87,28 @@ const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); const ATTACHMENT_SVG = ""; const ADD_PROJECT_SUBMENU_PLACEHOLDER = "Enter path (e.g. ~/projects/my-app)"; +const INITIAL_VCS_STATUS_EVENT = { + _tag: "snapshot" as const, + local: { + isRepo: true, + sourceControlProvider: { + kind: "github" as const, + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + remote: { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }, +}; interface TestFixture { snapshot: OrchestrationReadModel; @@ -239,38 +214,6 @@ function createBaseServerConfig(): ServerConfig { }; } -function createMockEnvironmentApi(input: { - browse: EnvironmentApi["filesystem"]["browse"]; - dispatchCommand: EnvironmentApi["orchestration"]["dispatchCommand"]; -}): EnvironmentApi { - return { - terminal: {} as EnvironmentApi["terminal"], - projects: {} as EnvironmentApi["projects"], - filesystem: { - browse: input.browse, - }, - sourceControl: {} as EnvironmentApi["sourceControl"], - vcs: {} as EnvironmentApi["vcs"], - git: {} as EnvironmentApi["git"], - review: {} as EnvironmentApi["review"], - orchestration: { - dispatchCommand: input.dispatchCommand, - getTurnDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getTurnDiff"], - getFullThreadDiff: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getFullThreadDiff"], - getArchivedShellSnapshot: (() => { - throw new Error("Not implemented in browser test."); - }) as EnvironmentApi["orchestration"]["getArchivedShellSnapshot"], - subscribeShell: (() => () => undefined) as EnvironmentApi["orchestration"]["subscribeShell"], - subscribeThread: (() => () => - undefined) as EnvironmentApi["orchestration"]["subscribeThread"], - }, - }; -} - function createUserMessage(options: { id: MessageId; text: string; @@ -569,11 +512,20 @@ function sendShellThreadUpsert( }); } -async function waitForWsClient(): Promise { +async function waitForWsClient(router?: ReturnType): Promise { await vi.waitFor( () => { + const receivedRequestTags = wsRequests.map((request) => request._tag).join(", "); + const diagnostics = [ + `requests=${receivedRequestTags}`, + `pathname=${router?.state.location.pathname ?? "unknown"}`, + `routerStatus=${router?.state.status ?? "unknown"}`, + `matches=${router?.state.matches.map((match) => match.routeId).join(",") ?? "unknown"}`, + `body=${document.body.textContent?.trim().slice(0, 200) || ""}`, + ].join("; "); expect( wsRequests.some((request) => request._tag === ORCHESTRATION_WS_METHODS.subscribeShell), + `Expected shell subscription. ${diagnostics}`, ).toBe(true); expect( wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), @@ -623,8 +575,7 @@ function serverThreadPath(threadId: ThreadId): string { async function waitForAppBootstrap(): Promise { await vi.waitFor( () => { - expect(getServerConfig()).not.toBeNull(); - expect(selectBootstrapCompleteForActiveEnvironment(useStore.getState())).toBe(true); + expect(appAtomRegistry.get(primaryServerConfigAtom)).not.toBeNull(); }, { timeout: 8_000, interval: 16 }, ); @@ -1632,16 +1583,11 @@ async function mountChatView(options: { }), ); - const screen = await render( - - - , - { - container: host, - }, - ); + const screen = await render(, { + container: host, + }); - await waitForWsClient(); + await waitForWsClient(router); await waitForAppBootstrap(); await waitForLayout(); @@ -1738,6 +1684,9 @@ describe("ChatView timeline estimator parity (full app)", () => { if (request._tag === WS_METHODS.subscribeTerminalMetadata) { return fixture.terminalMetadataEvents; } + if (request._tag === WS_METHODS.subscribeVcsStatus) { + return [INITIAL_VCS_STATUS_EVENT]; + } return []; }, }); @@ -1747,9 +1696,6 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; wsRequests.length = 0; customWsRpcResolver = null; - __resetEnvironmentApiOverridesForTests(); - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); Reflect.deleteProperty(window, "desktopBridge"); useComposerDraftStore.setState({ draftsByThreadKey: {}, @@ -1762,10 +1708,6 @@ describe("ChatView timeline estimator parity (full app)", () => { open: false, openIntent: null, }); - useStore.setState({ - activeEnvironmentId: null, - environmentStateById: {}, - }); useUiStateStore.setState({ projectExpandedById: {}, projectOrder: [], @@ -4063,24 +4005,6 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - await vi.waitFor( - () => { - expect( - terminalSessionManager.listSessions({ - environmentId: LOCAL_ENVIRONMENT_ID, - threadId: THREAD_ID, - }), - ).toMatchObject([ - { - state: { - hasRunningSubprocess: true, - }, - }, - ]); - }, - { timeout: 8_000, interval: 16 }, - ); - await vi.waitFor( () => { const terminalIndicator = document.querySelector( @@ -5266,132 +5190,6 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("selects an environment before browsing when multiple environments are available", async () => { - const remoteBrowseMock = vi.fn(async ({ partialPath }: { partialPath: string }) => { - if (partialPath === "~/workspaces/") { - return { - parentPath: "~/workspaces/", - entries: [{ name: "codething", fullPath: "~/workspaces/codething" }], - }; - } - - return { - parentPath: "~/", - entries: [{ name: "workspaces", fullPath: "~/workspaces" }], - }; - }); - const remoteDispatchMock = vi.fn(async () => ({ - sequence: fixture.snapshot.snapshotSequence + 1, - })); - - __setEnvironmentApiOverrideForTests( - REMOTE_ENVIRONMENT_ID, - createMockEnvironmentApi({ - browse: remoteBrowseMock, - dispatchCommand: remoteDispatchMock, - }), - ); - - const mounted = await mountChatView({ - viewport: DEFAULT_VIEWPORT, - snapshot: createSnapshotForTargetUser({ - targetMessageId: "msg-user-command-palette-add-project-multi-env" as MessageId, - targetText: "command palette add project multi env", - }), - }); - - try { - await waitForServerConfigToApply(); - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - httpBaseUrl: "https://staging.example.test", - wsBaseUrl: "wss://staging.example.test/ws", - createdAt: NOW_ISO, - lastConnectedAt: NOW_ISO, - }); - useSavedEnvironmentRuntimeStore.getState().patch(REMOTE_ENVIRONMENT_ID, { - connectionState: "connected", - authState: "authenticated", - descriptor: { - ...fixture.serverConfig.environment, - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - }, - serverConfig: { - ...fixture.serverConfig, - environment: { - ...fixture.serverConfig.environment, - environmentId: REMOTE_ENVIRONMENT_ID, - label: "Staging", - }, - settings: { - ...fixture.serverConfig.settings, - addProjectBaseDirectory: "~/workspaces", - }, - }, - connectedAt: NOW_ISO, - }); - - const palette = page.getByTestId("command-palette"); - await openCommandPaletteFromTrigger(); - - await expect.element(palette).toBeInTheDocument(); - await palette.getByText("Add project", { exact: true }).click(); - await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByText("This device", { exact: true }).first()) - .toBeInTheDocument(); - await palette.getByText("Staging", { exact: true }).click(); - await palette.getByText("Local folder", { exact: true }).click(); - - const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); - await expect.element(browseInput).toHaveValue("~/workspaces/"); - - await vi.waitFor( - () => { - expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); - }, - { timeout: 8_000, interval: 16 }, - ); - - await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/workspaces/"); - await vi.waitFor( - () => { - expect(remoteBrowseMock).toHaveBeenCalledWith({ partialPath: "~/workspaces/" }); - }, - { timeout: 8_000, interval: 16 }, - ); - await expect.element(palette.getByText("codething", { exact: true })).toBeInTheDocument(); - await expect - .element(palette.getByRole("button", { name: "Add (Enter)" })) - .toBeInTheDocument(); - - await dispatchInputKey(browseInput, { key: "Enter" }); - - await vi.waitFor( - () => { - expect(remoteDispatchMock).toHaveBeenCalledWith( - expect.objectContaining({ - type: "project.create", - workspaceRoot: "~/workspaces", - title: "workspaces", - }), - ); - }, - { timeout: 8_000, interval: 16 }, - ); - - await waitForURL( - mounted.router, - (path) => UUID_ROUTE_RE.test(path), - "Route should have changed to a new draft thread after adding a remote project.", - ); - } finally { - await mounted.cleanup(); - } - }); - it("picks a local project from the native file manager", async () => { const pickFolder = vi.fn().mockResolvedValue("/Users/julius/Projects/finder-picked"); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 13bd175e0c9..43ed895c0db 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,17 +1,9 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { - EnvironmentId, - ProjectId, - ProviderDriverKind, - ProviderInstanceId, - ThreadId, - TurnId, -} from "@t3tools/contracts"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { type EnvironmentState, useStore } from "../store"; -import { type Thread } from "../types"; +import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import type { Thread } from "../types"; import { + MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, buildExpiredTerminalContextToastCopy, createLocalDispatchSnapshot, @@ -19,12 +11,63 @@ import { getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, + reconcileRetainedMountedThreadIds, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, - waitForStartedServerThread, } from "./ChatView.logic"; -const localEnvironmentId = EnvironmentId.make("environment-local"); +const environmentId = EnvironmentId.make("environment-local"); +const projectId = ProjectId.make("project-1"); +const threadId = ThreadId.make("thread-1"); +const now = "2026-03-29T00:00:00.000Z"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: threadId, + environmentId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + session: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + createdAt: now, + updatedAt: now, + archivedAt: null, + deletedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + ...overrides, + }; +} + +const completedTurn = { + turnId: TurnId.make("turn-1"), + state: "completed" as const, + requestedAt: now, + startedAt: "2026-03-29T00:00:01.000Z", + completedAt: "2026-03-29T00:00:10.000Z", + assistantMessageId: null, +}; + +const readySession = { + threadId, + status: "ready" as const, + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: "2026-03-29T00:00:10.000Z", +}; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -34,13 +77,13 @@ describe("deriveComposerSendState", () => { terminalContexts: [ { id: "ctx-expired", - threadId: ThreadId.make("thread-1"), + threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", - createdAt: "2026-03-17T12:52:29.000Z", + createdAt: now, }, ], }); @@ -58,13 +101,13 @@ describe("deriveComposerSendState", () => { terminalContexts: [ { id: "ctx-expired", - threadId: ThreadId.make("thread-1"), + threadId, terminalId: "default", terminalLabel: "Terminal 1", lineStart: 4, lineEnd: 4, text: "", - createdAt: "2026-03-17T12:52:29.000Z", + createdAt: now, }, ], }); @@ -73,17 +116,38 @@ 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", () => { - it("formats clear empty-state guidance", () => { + it("formats empty and omission guidance", () => { expect(buildExpiredTerminalContextToastCopy(1, "empty")).toEqual({ title: "Expired terminal context won't be sent", description: "Remove it or re-add it to include terminal output.", }); - }); - - it("formats omission guidance for sent messages", () => { expect(buildExpiredTerminalContextToastCopy(2, "omitted")).toEqual({ title: "Expired terminal contexts omitted from message", description: "Re-add it if you want that terminal output included.", @@ -159,411 +223,118 @@ describe("getStartedThreadModelChangeBlockReason", () => { }); describe("resolveSendEnvMode", () => { - it("keeps worktree mode for git repositories", () => { + it("keeps worktree mode only for git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: true })).toBe("worktree"); - }); - - it("forces local mode for non-git repositories", () => { expect(resolveSendEnvMode({ requestedEnvMode: "worktree", isGitRepo: false })).toBe("local"); - expect(resolveSendEnvMode({ requestedEnvMode: "local", isGitRepo: false })).toBe("local"); }); }); describe("reconcileMountedTerminalThreadIds", () => { - it("keeps previously mounted open threads and adds the active open thread", () => { + it("keeps open threads and makes the active thread most recent", () => { expect( reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-stale")], - openThreadIds: [ThreadId.make("thread-hidden"), ThreadId.make("thread-active")], - activeThreadId: ThreadId.make("thread-active"), + currentThreadIds: ["thread-a", "thread-b", "thread-c"], + openThreadIds: ["thread-a", "thread-b", "thread-c"], + activeThreadId: "thread-a", activeThreadTerminalOpen: true, + maxHiddenThreadCount: 2, }), - ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); + ).toEqual(["thread-b", "thread-c", "thread-a"]); }); - it("drops mounted threads once their terminal drawer is no longer open", () => { + it("drops closed threads and enforces the hidden mounted cap", () => { + const ids = Array.from( + { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, + (_, index) => `thread-${index}`, + ); expect( reconcileMountedTerminalThreadIds({ - currentThreadIds: [ThreadId.make("thread-closed")], - openThreadIds: [], - activeThreadId: ThreadId.make("thread-closed"), + currentThreadIds: ids, + openThreadIds: ids.slice(1), + activeThreadId: null, activeThreadTerminalOpen: false, }), - ).toEqual([]); + ).toEqual(ids.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_THREADS)); }); +}); - it("keeps only the most recently active hidden terminal threads", () => { +describe("reconcileRetainedMountedThreadIds", () => { + it("retains hidden open threads and adds the active open thread", () => { expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ], - openThreadIds: [ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ], - activeThreadId: ThreadId.make("thread-4"), - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, + 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-2"), ThreadId.make("thread-3"), ThreadId.make("thread-4")]); + ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); }); - it("moves the active thread to the end so it is treated as most recently used", () => { + it("can retain the active thread as hidden when it is inactive", () => { expect( - reconcileMountedTerminalThreadIds({ - currentThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - openThreadIds: [ - ThreadId.make("thread-a"), - ThreadId.make("thread-b"), - ThreadId.make("thread-c"), - ], - activeThreadId: ThreadId.make("thread-a"), - activeThreadTerminalOpen: true, - maxHiddenThreadCount: 2, + 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-b"), ThreadId.make("thread-c"), ThreadId.make("thread-a")]); + ).toEqual([ThreadId.make("thread-active")]); }); - it("defaults to the hidden mounted terminal cap", () => { + it("evicts the oldest hidden threads beyond the configured cap", () => { const currentThreadIds = Array.from( - { length: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS + 2 }, + { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 }, (_, index) => ThreadId.make(`thread-${index + 1}`), ); expect( - reconcileMountedTerminalThreadIds({ + reconcileRetainedMountedThreadIds({ currentThreadIds, openThreadIds: currentThreadIds, activeThreadId: null, - activeThreadTerminalOpen: false, + activeThreadOpen: false, + maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, }), - ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_TERMINAL_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"); - const routeThreadRef = scopeThreadRef(localEnvironmentId, threadId); + it("requires the environment, route thread, and target thread to match", () => { + const routeThreadRef = { environmentId, threadId }; expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: { - environmentId: localEnvironmentId, - id: threadId, - }, + serverThread: { environmentId, id: threadId }, routeThreadRef, targetThreadId: threadId, }), ).toBe(true); - }); - - it("does not route draft-thread errors into server-backed state", () => { - const threadId = ThreadId.make("thread-1"); - expect( shouldWriteThreadErrorToCurrentServerThread({ - serverThread: undefined, - routeThreadRef: scopeThreadRef(localEnvironmentId, threadId), + serverThread: null, + routeThreadRef, targetThreadId: threadId, }), ).toBe(false); }); }); -const makeThread = (input?: { - id?: ThreadId; - latestTurn?: { - turnId: TurnId; - state: "running" | "completed"; - requestedAt: string; - startedAt: string | null; - completedAt: string | null; - } | null; -}): Thread => ({ - id: input?.id ?? ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access" as const, - interactionMode: "default" as const, - session: null, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:00.000Z", - latestTurn: input?.latestTurn - ? { - ...input.latestTurn, - assistantMessageId: null, - } - : null, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], -}); - -function setStoreThreads(threads: ReadonlyArray>) { - const projectId = ProjectId.make("project-1"); - const environmentState: EnvironmentState = { - projectIds: [projectId], - projectById: { - [projectId]: { - id: projectId, - environmentId: localEnvironmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - createdAt: "2026-03-29T00:00:00.000Z", - updatedAt: "2026-03-29T00:00:00.000Z", - scripts: [], - }, - }, - threadIds: threads.map((thread) => thread.id), - threadIdsByProjectId: { - [projectId]: threads.map((thread) => thread.id), - }, - threadShellById: Object.fromEntries( - threads.map((thread) => [ - thread.id, - { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - ]), - ), - threadSessionById: Object.fromEntries(threads.map((thread) => [thread.id, thread.session])), - threadTurnStateById: Object.fromEntries( - threads.map((thread) => [ - thread.id, - { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }, - ]), - ), - messageIdsByThreadId: Object.fromEntries( - threads.map((thread) => [thread.id, thread.messages.map((message) => message.id)]), - ), - messageByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.messages.map((message) => [message.id, message])), - ]), - ), - activityIdsByThreadId: Object.fromEntries( - threads.map((thread) => [thread.id, thread.activities.map((activity) => activity.id)]), - ), - activityByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.activities.map((activity) => [activity.id, activity])), - ]), - ), - proposedPlanIdsByThreadId: Object.fromEntries( - threads.map((thread) => [thread.id, thread.proposedPlans.map((plan) => plan.id)]), - ), - proposedPlanByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.proposedPlans.map((plan) => [plan.id, plan])), - ]), - ), - turnDiffIdsByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - thread.turnDiffSummaries.map((summary) => summary.turnId), - ]), - ), - turnDiffSummaryByThreadId: Object.fromEntries( - threads.map((thread) => [ - thread.id, - Object.fromEntries(thread.turnDiffSummaries.map((summary) => [summary.turnId, summary])), - ]), - ), - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - useStore.setState({ - activeEnvironmentId: localEnvironmentId, - environmentStateById: { - [localEnvironmentId]: environmentState, - }, - }); -} - -afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - setStoreThreads([]); -}); - -describe("waitForStartedServerThread", () => { - it("resolves immediately when the thread is already started", async () => { - const threadId = ThreadId.make("thread-started"); - setStoreThreads([ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.make("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ]); - - await expect( - waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId)), - ).resolves.toBe(true); - }); - - it("waits for the thread to start via subscription updates", async () => { - const threadId = ThreadId.make("thread-wait"); - setStoreThreads([makeThread({ id: threadId })]); - - const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); - - setStoreThreads([ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.make("turn-started"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ]); - - await expect(promise).resolves.toBe(true); - }); - - it("handles the thread starting between the initial read and subscription setup", async () => { - const threadId = ThreadId.make("thread-race"); - setStoreThreads([makeThread({ id: threadId })]); - - const originalSubscribe = useStore.subscribe.bind(useStore); - let raced = false; - vi.spyOn(useStore, "subscribe").mockImplementation((listener) => { - if (!raced) { - raced = true; - setStoreThreads([ - makeThread({ - id: threadId, - latestTurn: { - turnId: TurnId.make("turn-race"), - state: "running", - requestedAt: "2026-03-29T00:00:01.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: null, - }, - }), - ]); - } - return originalSubscribe(listener); - }); - - await expect( - waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500), - ).resolves.toBe(true); - }); - - it("returns false after the timeout when the thread never starts", async () => { - vi.useFakeTimers(); - - const threadId = ThreadId.make("thread-timeout"); - setStoreThreads([makeThread({ id: threadId })]); - const promise = waitForStartedServerThread(scopeThreadRef(localEnvironmentId, threadId), 500); - - await vi.advanceTimersByTimeAsync(500); - - await expect(promise).resolves.toBe(false); - }); -}); - describe("hasServerAcknowledgedLocalDispatch", () => { - const projectId = ProjectId.make("project-1"); - const previousLatestTurn = { - turnId: TurnId.make("turn-1"), - state: "completed" as const, - requestedAt: "2026-03-29T00:00:00.000Z", - startedAt: "2026-03-29T00:00:01.000Z", - completedAt: "2026-03-29T00:00:10.000Z", - assistantMessageId: null, - }; - - const previousSession = { - provider: ProviderDriverKind.make("codex"), - status: "ready" as const, - createdAt: "2026-03-29T00:00:00.000Z", - updatedAt: "2026-03-29T00:00:10.000Z", - orchestrationStatus: "idle" as const, - }; - - it("does not clear local dispatch before server state changes", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }); + it("does not acknowledge unchanged server state", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "ready", - latestTurn: previousLatestTurn, - session: previousSession, + latestTurn: completedTurn, + session: readySession, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -571,45 +342,24 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(false); }); - it("clears local dispatch when a new turn is already settled", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }); + it("acknowledges a settled newer turn", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + const newerTurn = { + ...completedTurn, + turnId: TurnId.make("turn-2"), + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: "2026-03-29T00:01:30.000Z", + }; expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "ready", - latestTurn: { - ...previousLatestTurn, - turnId: TurnId.make("turn-2"), - requestedAt: "2026-03-29T00:01:00.000Z", - startedAt: "2026-03-29T00:01:01.000Z", - completedAt: "2026-03-29T00:01:30.000Z", - }, - session: { - ...previousSession, - updatedAt: "2026-03-29T00:01:30.000Z", - }, + latestTurn: newerTurn, + session: { ...readySession, updatedAt: newerTurn.completedAt }, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -617,134 +367,43 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); - it("does not clear local dispatch while the session is running a newer turn than latestTurn", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }); - - expect( - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: "running", - latestTurn: previousLatestTurn, - session: { - ...previousSession, - status: "running", - orchestrationStatus: "running", - activeTurnId: TurnId.make("turn-2"), - updatedAt: "2026-03-29T00:01:00.000Z", - }, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }), - ).toBe(false); - }); - - it("does not clear local dispatch while the session is running but latestTurn has not advanced yet", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }); + it("waits for the matching running turn before acknowledging", () => { + const localDispatch = createLocalDispatchSnapshot( + makeThread({ latestTurn: completedTurn, session: readySession }), + ); + const runningTurn = { + ...completedTurn, + turnId: TurnId.make("turn-2"), + state: "running" as const, + requestedAt: "2026-03-29T00:01:00.000Z", + startedAt: "2026-03-29T00:01:01.000Z", + completedAt: null, + }; expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "running", - latestTurn: previousLatestTurn, + latestTurn: runningTurn, session: { - ...previousSession, + ...readySession, status: "running", - orchestrationStatus: "running", - activeTurnId: undefined, - updatedAt: "2026-03-29T00:01:00.000Z", + activeTurnId: TurnId.make("turn-other"), }, hasPendingApproval: false, hasPendingUserInput: false, threadError: null, }), ).toBe(false); - }); - - it("clears local dispatch once the running latestTurn matches the active session turn", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }); - expect( hasServerAcknowledgedLocalDispatch({ localDispatch, phase: "running", - latestTurn: { - ...previousLatestTurn, - turnId: TurnId.make("turn-2"), - state: "running", - requestedAt: "2026-03-29T00:01:00.000Z", - startedAt: "2026-03-29T00:01:01.000Z", - completedAt: null, - }, + latestTurn: runningTurn, session: { - ...previousSession, + ...readySession, status: "running", - orchestrationStatus: "running", - activeTurnId: TurnId.make("turn-2"), - updatedAt: "2026-03-29T00:01:01.000Z", + activeTurnId: runningTurn.turnId, }, hasPendingApproval: false, hasPendingUserInput: false, @@ -753,43 +412,20 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); - it("clears local dispatch when the session changes without an observed running phase", () => { - const localDispatch = createLocalDispatchSnapshot({ - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId, - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - session: previousSession, - messages: [], - proposedPlans: [], - error: null, - createdAt: "2026-03-29T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-03-29T00:00:10.000Z", - latestTurn: previousLatestTurn, - branch: null, - worktreePath: null, - turnDiffSummaries: [], - activities: [], - }); - - expect( - hasServerAcknowledgedLocalDispatch({ - localDispatch, - phase: "ready", - latestTurn: previousLatestTurn, - session: { - ...previousSession, - updatedAt: "2026-03-29T00:00:11.000Z", - }, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, - }), - ).toBe(true); + it("acknowledges pending user interaction and errors immediately", () => { + const localDispatch = createLocalDispatchSnapshot(makeThread()); + const common = { + localDispatch, + phase: "ready" as const, + latestTurn: null, + session: null, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }; + + expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); + expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); + expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index de69c573046..36947caae6f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -9,10 +9,11 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; +import { type ChatMessage, type SessionPhase, type Thread } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; -import { selectThreadByRef, useStore } from "../store"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentThreadDetails } from "../state/threads"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, @@ -22,6 +23,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); @@ -29,12 +31,10 @@ export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, fallbackModelSelection: ModelSelection, - error: string | null, ): Thread { return { id: threadId, environmentId: draftThread.environmentId, - codexThreadId: null, projectId: draftThread.projectId, title: "New thread", modelSelection: fallbackModelSelection, @@ -42,13 +42,14 @@ export function buildLocalDraftThread( interactionMode: draftThread.interactionMode, session: null, messages: [], - error, createdAt: draftThread.createdAt, + updatedAt: draftThread.createdAt, archivedAt: null, + deletedAt: null, latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, - turnDiffSummaries: [], + checkpoints: [], activities: [], proposedPlans: [], }; @@ -79,15 +80,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) @@ -95,7 +112,7 @@ export function reconcileMountedTerminalThreadIds(input: { if ( input.activeThreadId && - input.activeThreadTerminalOpen && + input.activeThreadOpen && !nextThreadIds.includes(input.activeThreadId) ) { nextThreadIds.push(input.activeThreadId); @@ -185,6 +202,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[]; @@ -195,12 +218,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, }; } @@ -248,8 +275,8 @@ export function deriveLockedProvider(input: { if (!threadHasStarted(input.thread)) { return null; } - const sessionProvider = input.thread?.session?.provider ?? null; - if (sessionProvider) { + const sessionProvider = input.thread?.session?.providerName ?? null; + if (sessionProvider && isProviderDriverKind(sessionProvider)) { return sessionProvider; } const narrowedThreadProvider = @@ -305,7 +332,8 @@ export async function waitForStartedServerThread( threadRef: ScopedThreadRef, timeoutMs = 1_000, ): Promise { - const getThread = () => selectThreadByRef(useStore.getState(), threadRef); + const threadAtom = environmentThreadDetails.detailAtom(threadRef); + const getThread = () => appAtomRegistry.get(threadAtom); const thread = getThread(); if (threadHasStarted(thread)) { @@ -327,8 +355,8 @@ export async function waitForStartedServerThread( resolve(result); }; - const unsubscribe = useStore.subscribe((state) => { - if (!threadHasStarted(selectThreadByRef(state, threadRef))) { + const unsubscribe = appAtomRegistry.subscribe(threadAtom, (thread) => { + if (!threadHasStarted(thread)) { return; } finish(true); @@ -352,7 +380,7 @@ export interface LocalDispatchSnapshot { latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; - sessionOrchestrationStatus: ThreadSession["orchestrationStatus"] | null; + sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; } @@ -369,7 +397,7 @@ export function createLocalDispatchSnapshot( latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, latestTurnCompletedAt: latestTurn?.completedAt ?? null, - sessionOrchestrationStatus: session?.orchestrationStatus ?? null, + sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, }; } @@ -406,8 +434,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { return false; } if ( + session?.activeTurnId !== null && session?.activeTurnId !== undefined && - session.activeTurnId !== null && latestTurn?.turnId !== session.activeTurnId ) { return false; @@ -417,7 +445,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { return ( latestTurnChanged || - input.localDispatch.sessionOrchestrationStatus !== (session?.orchestrationStatus ?? null) || + input.localDispatch.sessionStatus !== (session?.status ?? null) || input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 75fc0ad3235..59136ec6952 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,12 +21,16 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import { parseScopedThreadKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import { applyClaudePromptEffortPrefix, createModelSelection, @@ -36,12 +40,10 @@ import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/proje 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 { useAtomSet, useAtomValue } from "@effect/atom-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 { readEnvironmentApi } from "../environmentApi"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -70,12 +72,6 @@ import { togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../pendingUserInput"; -import { - selectProjectsAcrossEnvironments, - selectThreadsAcrossEnvironments, - useStore, -} from "../store"; -import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { useUiStateStore } from "../uiStateStore"; import { buildPlanImplementationThreadTitle, @@ -98,6 +94,19 @@ import { useCommandPaletteStore } from "../commandPaletteStore"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; +import { + selectActiveRightPanelSurface, + selectThreadRightPanelState, + useRightPanelStore, +} from "../rightPanelStore"; +import { + isPreviewSupportedInRuntime, + selectThreadPreviewState, + usePreviewStateStore, +} from "../previewStateStore"; +import { subscribePreviewAction } from "./preview/previewActionBus"; +import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; @@ -112,7 +121,7 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; +import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -121,11 +130,6 @@ import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, } from "../logicalProject"; -import { - reconnectSavedEnvironment, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import { buildDraftThreadRouteParams } from "../threadRoutes"; import { type ComposerImageAttachment, @@ -139,8 +143,37 @@ import { type TerminalContextDraft, type TerminalContextSelection, } from "../lib/terminalContext"; +import { + appendElementContextsToPrompt, + type ElementContextDraft, + formatElementContextLabel, +} from "../lib/elementContext"; +import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { + primaryServerAvailableEditorsAtom, + primaryServerKeybindingsAtom, + serverEnvironment, +} from "../state/server"; +import { terminalEnvironment } from "../state/terminal"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { + useEnvironmentActions, + useEnvironmentHttpBaseUrl, + useEnvironments, + usePrimaryEnvironment, +} from "../state/environments"; +import { + useProject, + useProjects, + useThreadDetail, + useThreadProposedPlans, + useThreadRefs, +} from "../state/entities"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -148,7 +181,7 @@ import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { NoActiveThreadState } from "./NoActiveThreadState"; -import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; +import { resolveEffectiveEnvMode } from "./BranchToolbar.logic"; import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; @@ -172,19 +205,13 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - shouldWriteThreadErrorToCurrentServerThread, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; -import { - useServerAvailableEditors, - useServerConfig, - useServerKeybindings, -} from "~/rpc/serverState"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; -import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { RightPanelSheet } from "./RightPanelSheet"; +import { usePreviewActions } from "../state/preview"; import { Button } from "./ui/button"; import { buildVersionMismatchDismissalKey, @@ -196,10 +223,12 @@ import { const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; -const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const PreviewPanel = lazy(() => + import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), +); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ "input", "textarea", @@ -232,7 +261,7 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; }; type ThreadPlanCatalogEntry = Pick; @@ -256,119 +285,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } -function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalogEntry[] { - return useStore( - useMemo(() => { - let previousThreadIds: readonly ThreadId[] = []; - let previousResult: ThreadPlanCatalogEntry[] = []; - let previousEntries = new Map< - ThreadId, - { - shell: object | null; - proposedPlanIds: readonly string[] | undefined; - proposedPlansById: Record | undefined; - entry: ThreadPlanCatalogEntry; - } - >(); - - return (state) => { - const sameThreadIds = - previousThreadIds.length === threadIds.length && - previousThreadIds.every((id, index) => id === threadIds[index]); - const nextEntries = new Map< - ThreadId, - { - shell: object | null; - proposedPlanIds: readonly string[] | undefined; - proposedPlansById: Record | undefined; - entry: ThreadPlanCatalogEntry; - } - >(); - const nextResult: ThreadPlanCatalogEntry[] = []; - let changed = !sameThreadIds; - - for (const threadId of threadIds) { - let shell: object | undefined; - let proposedPlanIds: readonly string[] | undefined; - let proposedPlansById: Record | undefined; - - for (const environmentState of Object.values(state.environmentStateById)) { - const matchedShell = environmentState.threadShellById[threadId]; - if (!matchedShell) { - continue; - } - shell = matchedShell; - proposedPlanIds = environmentState.proposedPlanIdsByThreadId[threadId]; - proposedPlansById = environmentState.proposedPlanByThreadId[threadId] as - | Record - | undefined; - break; - } - - if (!shell) { - const previous = previousEntries.get(threadId); - if ( - previous && - previous.shell === null && - previous.proposedPlanIds === undefined && - previous.proposedPlansById === undefined - ) { - nextEntries.set(threadId, previous); - continue; - } - changed = true; - nextEntries.set(threadId, { - shell: null, - proposedPlanIds: undefined, - proposedPlansById: undefined, - entry: { id: threadId, proposedPlans: EMPTY_PROPOSED_PLANS }, - }); - continue; - } - - const previous = previousEntries.get(threadId); - if ( - previous && - previous.shell === shell && - previous.proposedPlanIds === proposedPlanIds && - previous.proposedPlansById === proposedPlansById - ) { - nextEntries.set(threadId, previous); - nextResult.push(previous.entry); - continue; - } - - changed = true; - const proposedPlans = - proposedPlanIds && proposedPlanIds.length > 0 && proposedPlansById - ? proposedPlanIds.flatMap((planId) => { - const proposedPlan = proposedPlansById?.[planId]; - return proposedPlan ? [proposedPlan] : []; - }) - : EMPTY_PROPOSED_PLANS; - const entry = { id: threadId, proposedPlans }; - nextEntries.set(threadId, { - shell, - proposedPlanIds, - proposedPlansById, - entry, - }); - nextResult.push(entry); - } - - if (!changed && previousResult.length === nextResult.length) { - return previousResult; - } - - previousThreadIds = threadIds; - previousEntries = nextEntries; - previousResult = nextResult; - return nextResult; - }; - }, [threadIds]), - ); -} - function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -540,14 +456,18 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra keybindings, onAddTerminalContext, }: PersistentThreadTerminalDrawerProps) { - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); + const openTerminal = useAtomSet(terminalEnvironment.open, { mode: "promise" }); + const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" }); + const clearTerminal = useAtomSet(terminalEnvironment.clear, { mode: "promise" }); + const closeTerminalMutation = useAtomSet(terminalEnvironment.close, { mode: "promise" }); + const serverThread = useThreadDetail(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 project = useProject(projectRef); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); @@ -589,7 +509,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: worktreePathForLaunch, }), }); @@ -632,7 +552,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra launchContext?.cwd ?? (project ? projectScriptCwd({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : null), @@ -642,7 +562,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => project ? projectScriptRuntimeEnv({ - project: { cwd: project.cwd }, + project: { cwd: project.workspaceRoot }, worktreePath: effectiveWorktreePath, }) : {}, @@ -664,8 +584,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra ); const splitTerminal = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); @@ -673,12 +592,15 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId(); void (async () => { try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, + await openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -693,11 +615,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeSplitTerminal, threadId, threadRef, + openTerminal, ]); const createNewTerminal = useCallback(() => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api || !cwd) { + if (!cwd) { return; } const terminalId = nextTerminalId(serverOrderedTerminalIds); @@ -705,12 +627,15 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId(); void (async () => { try { - await api.terminal.open({ - threadId, - terminalId, - cwd, - ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), - env: runtimeEnv, + await openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, }); } catch { // Opening failed; the tab is already in the store — user can retry or close it. @@ -725,6 +650,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra storeNewTerminal, threadId, threadRef, + openTerminal, ]); const activateTerminal = useCallback( @@ -737,31 +663,43 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra const closeTerminal = useCallback( (terminalId: string) => { - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) return; const isFinalTerminal = terminalUiState.terminalIds.length <= 1; const fallbackExitWrite = () => - api.terminal.write({ threadId, terminalId, data: "exit\n" }).catch(() => undefined); - - if ("close" in api.terminal && typeof api.terminal.close === "function") { - void (async () => { - if (isFinalTerminal) { - await api.terminal.clear({ threadId, terminalId }).catch(() => undefined); - } - await api.terminal.close({ + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }).catch(() => undefined); + + void (async () => { + if (isFinalTerminal) { + await clearTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId }, + }).catch(() => undefined); + } + await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, deleteHistory: true, - }); - })().catch(() => fallbackExitWrite()); - } else { - void fallbackExitWrite(); - } + }, + }); + })().catch(() => fallbackExitWrite()); storeCloseTerminal(threadRef, terminalId); bumpFocusRequestId(); }, - [bumpFocusRequestId, storeCloseTerminal, terminalUiState.terminalIds, threadId, threadRef], + [ + bumpFocusRequestId, + storeCloseTerminal, + terminalUiState.terminalIds, + threadId, + threadRef, + clearTerminal, + closeTerminalMutation, + writeTerminal, + ], ); const handleAddTerminalContext = useCallback( @@ -825,15 +763,48 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const updateProject = useAtomSet(projectEnvironment.update, { mode: "promise" }); + const upsertKeybinding = useAtomSet(serverEnvironment.upsertKeybinding, { mode: "promise" }); + const openTerminal = useAtomSet(terminalEnvironment.open, { mode: "promise" }); + const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" }); + const clearTerminal = useAtomSet(terminalEnvironment.clear, { mode: "promise" }); + const closeTerminalMutation = useAtomSet(terminalEnvironment.close, { mode: "promise" }); + const createThread = useAtomSet(threadEnvironment.create, { mode: "promise" }); + const deleteThread = useAtomSet(threadEnvironment.delete, { mode: "promise" }); + const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, { + mode: "promise", + }); + const setThreadRuntimeMode = useAtomSet(threadEnvironment.setRuntimeMode, { + mode: "promise", + }); + const setThreadInteractionMode = useAtomSet(threadEnvironment.setInteractionMode, { + mode: "promise", + }); + const startThreadTurn = useAtomSet(threadEnvironment.startTurn, { mode: "promise" }); + const interruptThreadTurn = useAtomSet(threadEnvironment.interruptTurn, { + mode: "promise", + }); + const respondToThreadApproval = useAtomSet(threadEnvironment.respondToApproval, { + mode: "promise", + }); + const respondToThreadUserInput = useAtomSet(threadEnvironment.respondToUserInput, { + mode: "promise", + }); + const revertThreadCheckpoint = useAtomSet(threadEnvironment.revertCheckpoint, { + mode: "promise", + }); + const { open: openPreview } = usePreviewActions(); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); + const { retryEnvironment } = useEnvironmentActions(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; - const serverThread = useStore( - useMemo( - () => createThreadSelectorByRef(routeKind === "server" ? routeThreadRef : null), - [routeKind, routeThreadRef], - ), - ); - const setStoreThreadError = useStore((store) => store.setError); + const serverThread = useThreadDetail(routeKind === "server" ? routeThreadRef : null); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore((store) => routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, @@ -865,6 +836,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( @@ -889,6 +866,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); @@ -899,6 +877,9 @@ export default function ChatView(props: ChatViewProps) { const [localDraftErrorsByDraftId, setLocalDraftErrorsByDraftId] = useState< Record >({}); + const [localServerErrorsByThreadKey, setLocalServerErrorsByThreadKey] = useState< + Record + >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [respondingRequestIds, setRespondingRequestIds] = useState([]); @@ -956,13 +937,8 @@ export default function ChatView(props: ChatViewProps) { 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 serverThreadRefs = useThreadRefs(); + const serverThreadKeys = useMemo(() => serverThreadRefs.map(scopedThreadKey), [serverThreadRefs]); const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); const draftThreadKeys = useMemo( () => @@ -984,13 +960,12 @@ export default function ChatView(props: ChatViewProps) { const fallbackDraftProjectRef = draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) : null; - const fallbackDraftProject = useStore( - useMemo(() => createProjectSelectorByRef(fallbackDraftProjectRef), [fallbackDraftProjectRef]), - ); + const fallbackDraftProject = useProject(fallbackDraftProjectRef); const localDraftError = routeKind === "server" && serverThread ? null : ((draftId ? localDraftErrorsByDraftId[draftId] : null) ?? null); + const localServerError = localServerErrorsByThreadKey[routeThreadKey] ?? null; const localDraftThread = useMemo( () => draftThread @@ -1001,13 +976,15 @@ export default function ChatView(props: ChatViewProps) { instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }, - localDraftError, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], + [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], ); - const isServerThread = routeKind === "server" && serverThread !== undefined; + const isServerThread = routeKind === "server" && serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; + const threadError = isServerThread + ? (localServerError ?? serverThread?.session?.lastError ?? null) + : localDraftError; const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE; @@ -1045,6 +1022,26 @@ export default function ChatView(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const rightPanelState = useRightPanelStore((state) => + selectThreadRightPanelState(state.byThreadKey, activeThreadRef), + ); + const activeRightPanelSurface = useRightPanelStore((state) => + selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), + ); + const activePreviewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, activeThreadRef), + ); + const previewPanelOpen = + rightPanelState.isOpen && + activeRightPanelSurface?.kind === "preview" && + isPreviewSupportedInRuntime(); + + useEffect(() => { + if (!activeThreadRef) return; + useRightPanelStore + .getState() + .reconcileBrowserSurfaces(activeThreadRef, Object.keys(activePreviewState.sessions)); + }, [activePreviewState.sessions, activeThreadRef]); useEffect(() => { if (!activeThreadRef) { @@ -1074,19 +1071,29 @@ export default function ChatView(props: ChatViewProps) { return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); const activeLatestTurn = activeThread?.latestTurn ?? null; - const threadPlanCatalog = useThreadPlanCatalog( - useMemo(() => { - const threadIds: ThreadId[] = []; - if (activeThread?.id) { - threadIds.push(activeThread.id); - } - const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; - if (sourceThreadId && sourceThreadId !== activeThread?.id) { - threadIds.push(sourceThreadId); - } - return threadIds; - }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread?.id]), - ); + const sourcePlanThreadRef = useMemo(() => { + const sourceThreadId = activeLatestTurn?.sourceProposedPlan?.threadId; + if (!activeThread || !sourceThreadId || sourceThreadId === activeThread.id) { + return null; + } + return scopeThreadRef(activeThread.environmentId, sourceThreadId); + }, [activeLatestTurn?.sourceProposedPlan?.threadId, activeThread]); + const sourceThreadProposedPlans = useThreadProposedPlans(sourcePlanThreadRef); + const threadPlanCatalog = useMemo(() => { + if (!activeThread) { + return []; + } + const entries: ThreadPlanCatalogEntry[] = [ + { id: activeThread.id, proposedPlans: activeThread.proposedPlans }, + ]; + if (sourcePlanThreadRef) { + entries.push({ + id: sourcePlanThreadRef.threadId, + proposedPlans: sourceThreadProposedPlans, + }); + } + return entries; + }, [activeThread, sourcePlanThreadRef, sourceThreadProposedPlans]); useEffect(() => { setMountedTerminalThreadKeys((currentThreadIds) => { const nextThreadIds = reconcileMountedTerminalThreadIds({ @@ -1106,81 +1113,37 @@ export default function ChatView(props: ChatViewProps) { const activeProjectRef = activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null; - const activeProject = useStore( - useMemo(() => createProjectSelectorByRef(activeProjectRef), [activeProjectRef]), + const activeProject = useProject(activeProjectRef); + const configuredPreviewUrls = useMemo( + () => getConfiguredPreviewUrls(activeProject?.scripts), + [activeProject?.scripts], ); - useEffect(() => { - if (routeKind !== "server") { - return; - } - return retainThreadDetailSubscription(environmentId, threadId); - }, [environmentId, routeKind, threadId]); - // Compute the list of environments this logical project spans, used to // drive the environment picker in BranchToolbar. - const allProjects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); - const activeSavedEnvironmentRecord = - activeThread && activeThread.environmentId !== primaryEnvironmentId - ? (savedEnvironmentRegistry[activeThread.environmentId] ?? null) - : null; - const activeSavedEnvironmentRuntime = activeSavedEnvironmentRecord - ? (savedEnvironmentRuntimeById[activeSavedEnvironmentRecord.environmentId] ?? null) - : null; - const activeSavedEnvironmentConnectionState = activeSavedEnvironmentRecord - ? (activeSavedEnvironmentRuntime?.connectionState ?? "disconnected") - : "connected"; + const allProjects = useProjects(); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const activeEnvironment = + activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); + const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; const activeEnvironmentUnavailable = - activeSavedEnvironmentRecord !== null && activeSavedEnvironmentConnectionState !== "connected"; - const activeSavedEnvironmentId = activeSavedEnvironmentRecord?.environmentId ?? null; - const activeEnvironmentUnavailableLabel = activeSavedEnvironmentRecord - ? resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: activeSavedEnvironmentRecord.environmentId, - runtimeLabel: activeSavedEnvironmentRuntime?.descriptor?.label ?? null, - savedLabel: activeSavedEnvironmentRecord.label, - }) - : null; + activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; + const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; const activeEnvironmentUnavailableState = useMemo(() => { - if ( - !activeEnvironmentUnavailable || - !activeEnvironmentUnavailableLabel || - !activeSavedEnvironmentId - ) { + if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { return null; } return { - environmentId: activeSavedEnvironmentId, + environmentId: activeEnvironment.environmentId, label: activeEnvironmentUnavailableLabel, - connectionState: - activeSavedEnvironmentConnectionState === "connecting" || - activeSavedEnvironmentConnectionState === "error" - ? activeSavedEnvironmentConnectionState - : "disconnected", + connection: activeEnvironment.connection, }; - }, [ - activeEnvironmentUnavailable, - activeEnvironmentUnavailableLabel, - activeSavedEnvironmentConnectionState, - activeSavedEnvironmentId, - ]); - const [reconnectingEnvironmentId, setReconnectingEnvironmentId] = useState( - null, - ); + }, [activeEnvironment, activeEnvironmentUnavailable, activeEnvironmentUnavailableLabel]); const handleReconnectActiveEnvironment = useCallback( - async (environmentId: EnvironmentId, label: string) => { - setReconnectingEnvironmentId(environmentId); + async (environmentId: EnvironmentId) => { try { - await reconnectSavedEnvironment(environmentId); - toastManager.add({ - type: "success", - title: "Environment reconnected", - description: `${label} is ready.`, - }); + await retryEnvironment(environmentId); } catch (error) { toastManager.add( stackedThreadToast({ @@ -1189,11 +1152,9 @@ export default function ChatView(props: ChatViewProps) { description: error instanceof Error ? error.message : "Failed to reconnect.", }), ); - } finally { - setReconnectingEnvironmentId(null); } }, - [], + [retryEnvironment], ); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const logicalProjectEnvironments = useMemo(() => { @@ -1213,14 +1174,7 @@ export default function ChatView(props: ChatViewProps) { if (seen.has(p.environmentId)) continue; seen.add(p.environmentId); const isPrimary = p.environmentId === primaryEnvironmentId; - const savedRecord = savedEnvironmentRegistry[p.environmentId]; - const runtimeState = savedEnvironmentRuntimeById[p.environmentId]; - const label = resolveEnvironmentOptionLabel({ - isPrimary, - environmentId: p.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: savedRecord?.label ?? null, - }); + const label = environmentById.get(p.environmentId)?.label ?? p.environmentId; envs.push({ environmentId: p.environmentId, projectId: p.id, @@ -1234,14 +1188,7 @@ export default function ChatView(props: ChatViewProps) { return a.label.localeCompare(b.label); }); return envs; - }, [ - activeProject, - allProjects, - projectGroupingSettings, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [activeProject, allProjects, projectGroupingSettings, primaryEnvironmentId, environmentById]); const hasMultipleEnvironments = logicalProjectEnvironments.length > 1; const openPullRequestDialog = useCallback( @@ -1381,17 +1328,7 @@ export default function ChatView(props: ChatViewProps) { selectedProvider: selectedProviderByThreadId, threadProvider, }); - const primaryServerConfig = useServerConfig(); - const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => - activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, - ); - // Use the server config for the thread's environment. For the primary - // environment fall back to the global atom; for remote environments use - // the runtime state stored by the environment manager. - const serverConfig = - primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId - ? primaryServerConfig - : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); + const serverConfig = activeEnvironment?.serverConfig ?? primaryEnvironment?.serverConfig ?? null; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -1405,65 +1342,37 @@ export default function ChatView(props: ChatViewProps) { isVersionMismatchDismissed(versionMismatchDismissKey); const showVersionMismatchBanner = versionMismatch !== null && versionMismatchDismissKey !== null && !versionMismatchDismissed; - const hasMultipleRegisteredEnvironments = Object.keys(savedEnvironmentRegistry).length > 0; - const versionMismatchServerLabel = useMemo(() => { - if (!hasMultipleRegisteredEnvironments || !activeThread) { - return "server"; - } - - const isPrimary = activeThread.environmentId === primaryEnvironmentId; - const savedRecord = savedEnvironmentRegistry[activeThread.environmentId]; - const runtimeState = savedEnvironmentRuntimeById[activeThread.environmentId]; - return `${resolveEnvironmentOptionLabel({ - isPrimary, - environmentId: activeThread.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? serverConfig?.environment.label ?? null, - savedLabel: savedRecord?.label ?? null, - })} server`; - }, [ - activeThread, - hasMultipleRegisteredEnvironments, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - serverConfig?.environment.label, - ]); + const hasMultipleRegisteredEnvironments = environments.length > 1; + const versionMismatchServerLabel = + hasMultipleRegisteredEnvironments && activeThread + ? `${environmentById.get(activeThread.environmentId)?.label ?? serverConfig?.environment.label ?? activeThread.environmentId} server` + : "server"; const composerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; if (activeEnvironmentUnavailableState) { + const connection = activeEnvironmentUnavailableState.connection; + const isReconnecting = + connection.phase === "connecting" || connection.phase === "reconnecting"; items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: - activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", + variant: connection.phase === "error" ? "error" : "warning", icon: , - title: ( - <> - {activeEnvironmentUnavailableState.label} is{" "} - {activeEnvironmentUnavailableState.connectionState === "connecting" - ? "connecting" - : "disconnected"} - - ), - description: "Reconnect this environment before sending messages or running actions.", + title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusText(connection)}`, + description: + connection.error ?? + "Reconnect this environment before sending messages or running actions.", actions: ( <> +
+ )} + - {/* 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} + {/* end chat column */} + + {/* Plan sidebar */} + {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( + ) : null}
- {/* end chat column */} - - {/* Plan sidebar */} - {planSidebarOpen && !shouldUsePlanSidebarSheet ? ( - ( + + ))} + {shouldUsePlanSidebarSheet ? ( + + + ) : null} - {/* end horizontal flex container */} - - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} - {shouldUsePlanSidebarSheet ? ( - - + + + ) : null} + {previewPanelOpen && activeThreadRef && shouldUsePlanSidebarSheet ? ( + + + + ) : null} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index eb5fec9a91b..651fe34e4b4 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -14,7 +14,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: LOCAL_ENVIRONMENT_ID, - codexThreadId: null, projectId: PROJECT_ID, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, @@ -23,14 +22,14 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], - error: null, createdAt: "2026-03-01T00:00:00.000Z", archivedAt: null, + deletedAt: null, updatedAt: "2026-03-01T00:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, - turnDiffSummaries: [], + checkpoints: [], activities: [], ...overrides, }; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 982950be5e5..ab53adbefb1 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -100,9 +100,9 @@ export function buildProjectActionItems(input: { return input.projects.map((project) => ({ kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [project.name, project.cwd], - title: project.name, - description: project.cwd, + searchTerms: [project.title, project.workspaceRoot], + title: project.title, + description: project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), run: async () => { @@ -115,7 +115,7 @@ export type BuildThreadActionItemsThread = Pick< SidebarThreadSummary, "archivedAt" | "branch" | "createdAt" | "environmentId" | "id" | "projectId" | "title" > & { - updatedAt?: string | undefined; + updatedAt: string; latestUserMessageAt?: string | null; }; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ebfc260ca43..a8ae282d7d0 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ "use client"; -import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { DEFAULT_MODEL, type EnvironmentId, @@ -11,7 +11,6 @@ import { type SourceControlProviderKind, type SourceControlRepositoryInfo, } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { @@ -37,21 +36,17 @@ import { type KeyboardEvent, type ReactNode, } from "react"; -import { useShallow } from "zustand/react/shallow"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { useCommandPaletteStore } from "../commandPaletteStore"; -import { readEnvironmentApi } from "../environmentApi"; -import { readPrimaryEnvironmentDescriptor, usePrimaryEnvironmentId } from "../environments/primary"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useSettings } from "../hooks/useSettings"; import { readLocalApi } from "../localApi"; -import { - getSourceControlDiscoverySnapshot, - refreshSourceControlDiscovery, -} from "../lib/sourceControlDiscoveryState"; +import { filesystemEnvironment } from "../state/filesystem"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { useProjects, useThreadShells } from "../state/entities"; import { startNewThreadInProjectFromContext, startNewThreadFromContext, @@ -73,12 +68,7 @@ import { } from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; import { getLatestThreadForProject } from "../lib/threadSort"; -import { cn, isMacPlatform, isWindowsPlatform, newCommandId, newProjectId } from "../lib/utils"; -import { - selectProjectsAcrossEnvironments, - selectSidebarThreadsAcrossEnvironments, - useStore, -} from "../store"; +import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { @@ -102,7 +92,7 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { useServerKeybindings } from "../rpc/serverState"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { resolveShortcutCommand } from "../keybindings"; import { Command, @@ -120,7 +110,6 @@ import { ComposerHandleContext, useComposerHandleContext } from "../composerHand import type { ChatComposerHandle } from "./chat/ChatComposer"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; -const BROWSE_STALE_TIME_MS = 30_000; function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { @@ -330,7 +319,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const open = useCommandPaletteStore((store) => store.open); const setOpen = useCommandPaletteStore((store) => store.setOpen); const toggleOpen = useCommandPaletteStore((store) => store.toggleOpen); - const keybindings = useServerKeybindings(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -399,14 +388,22 @@ function OpenCommandPaletteDialog() { const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); - const queryClient = useQueryClient(); const [highlightedItemValue, setHighlightedItemValue] = useState(null); const settings = useSettings(); + const createProject = useAtomSet(projectEnvironment.create, { mode: "promise" }); + const lookupRepository = useAtomSet(sourceControlEnvironment.lookupRepository, { + mode: "promise", + }); + const cloneRepository = useAtomSet(sourceControlEnvironment.cloneRepository, { + mode: "promise", + }); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); - const keybindings = useServerKeybindings(); + const projects = useProjects(); + const threads = useThreadShells(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -417,45 +414,21 @@ function OpenCommandPaletteDialog() { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((state) => state.byId); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; const addProjectEnvironmentOptions = useMemo(() => { - const options: AddProjectEnvironmentOption[] = []; - const seenEnvironmentIds = new Set(); - - if (primaryEnvironmentId) { - seenEnvironmentIds.add(primaryEnvironmentId); - options.push({ - environmentId: primaryEnvironmentId, - label: resolveEnvironmentOptionLabel({ - isPrimary: true, - environmentId: primaryEnvironmentId, - runtimeLabel: primaryEnvironmentLabel, - }), - isPrimary: true, - }); - } - - for (const record of Object.values(savedEnvironmentRegistry)) { - if (seenEnvironmentIds.has(record.environmentId)) { - continue; - } - - const runtimeState = savedEnvironmentRuntimeById[record.environmentId]; - options.push({ - environmentId: record.environmentId, + const options = environments.map((environment): AddProjectEnvironmentOption => { + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + return { + environmentId: environment.environmentId, label: resolveEnvironmentOptionLabel({ - isPrimary: false, - environmentId: record.environmentId, - runtimeLabel: runtimeState?.descriptor?.label ?? null, - savedLabel: record.label, + isPrimary, + environmentId: environment.environmentId, + runtimeLabel: environment.label, }), - isPrimary: false, - }); - } + isPrimary, + }; + }); options.sort((left, right) => { if (left.isPrimary !== right.isPrimary) { @@ -465,26 +438,22 @@ function OpenCommandPaletteDialog() { }); return options; - }, [ - primaryEnvironmentId, - primaryEnvironmentLabel, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [environments]); const defaultAddProjectEnvironmentId = addProjectEnvironmentOptions[0]?.environmentId ?? null; const browseEnvironmentId = addProjectEnvironmentId ?? defaultAddProjectEnvironmentId; - const browseEnvironmentPlatform = useMemo(() => { - const os = - browseEnvironmentId && primaryEnvironmentId && browseEnvironmentId === primaryEnvironmentId - ? (readPrimaryEnvironmentDescriptor()?.platform.os ?? null) - : browseEnvironmentId - ? (savedEnvironmentRuntimeById[browseEnvironmentId]?.descriptor?.platform.os ?? - savedEnvironmentRuntimeById[browseEnvironmentId]?.serverConfig?.environment.platform - .os ?? - null) - : null; - return getEnvironmentBrowsePlatform(os); - }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); + const browseEnvironment = + environments.find((environment) => environment.environmentId === browseEnvironmentId) ?? null; + const sourceControlDiscovery = useEnvironmentQuery( + browseEnvironmentId === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: browseEnvironmentId, + input: {}, + }), + ); + const browseEnvironmentPlatform = getEnvironmentBrowsePlatform( + browseEnvironment?.serverConfig?.environment.platform.os, + ); const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; const isBrowsing = @@ -492,27 +461,28 @@ function OpenCommandPaletteDialog() { const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { + const environment = environments.find( + (candidate) => candidate.environmentId === environmentId, + ); const environmentSettings = - environmentId && primaryEnvironmentId && environmentId === primaryEnvironmentId - ? settings - : environmentId - ? savedEnvironmentRuntimeById[environmentId]?.serverConfig?.settings - : null; + environment?.serverConfig?.settings ?? + (environmentId === primaryEnvironmentId ? settings : null); const baseDirectory = environmentSettings?.addProjectBaseDirectory?.trim() ?? ""; if (baseDirectory.length === 0) { return "~/"; } return ensureBrowseDirectoryPath(baseDirectory); }, - [primaryEnvironmentId, savedEnvironmentRuntimeById, settings], + [environments, primaryEnvironmentId, settings], ); const projectCwdById = useMemo( - () => new Map(projects.map((project) => [project.id, project.cwd])), + () => + new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); const projectTitleById = useMemo( - () => new Map(projects.map((project) => [project.id, project.name])), + () => new Map(projects.map((project) => [project.id, project.title])), [projects], ); @@ -532,69 +502,28 @@ function OpenCommandPaletteDialog() { const browseDirectoryPath = isBrowsing ? getBrowseDirectoryPath(query) : ""; const browseFilterQuery = isBrowsing && !hasTrailingPathSeparator(query) ? getBrowseLeafPathSegment(query) : ""; - - const fetchBrowseResult = useCallback( - async (partialPath: string): Promise => { - if (!browseEnvironmentId) return null; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return null; - return api.filesystem.browse({ - partialPath, - ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse], - ); - - const { data: browseResult, isPending: isBrowsePending } = useQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - browseDirectoryPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(browseDirectoryPath), - staleTime: BROWSE_STALE_TIME_MS, - enabled: - isBrowsing && + const browseQuery = useEnvironmentQuery( + isBrowsing && browseDirectoryPath.length > 0 && browseEnvironmentId !== null && - !relativePathNeedsActiveProject, - }); + !relativePathNeedsActiveProject + ? filesystemEnvironment.browse({ + environmentId: browseEnvironmentId, + input: { + partialPath: browseDirectoryPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + }, + }) + : null, + ); + const browseResult = browseQuery.data; + const isBrowsePending = browseQuery.isPending; const browseEntries = browseResult?.entries ?? EMPTY_BROWSE_ENTRIES; const { filteredEntries: filteredBrowseEntries, exactEntry: exactBrowseEntry } = useMemo( () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), [browseEntries, browseFilterQuery, highlightedItemValue], ); - const prefetchBrowsePath = useCallback( - (partialPath: string) => { - void queryClient.prefetchQuery({ - queryKey: [ - "filesystemBrowse", - browseEnvironmentId, - partialPath, - currentProjectCwdForBrowse, - ], - queryFn: () => fetchBrowseResult(partialPath), - staleTime: BROWSE_STALE_TIME_MS, - }); - }, - [browseEnvironmentId, currentProjectCwdForBrowse, fetchBrowseResult, queryClient], - ); - - // Prefetch only the parent (for back-navigation). Prefetching the - // highlighted child on every arrow-key press triggers a macOS TCC prompt - // whenever the highlighted entry is a permission-gated home dir (Music, - // Documents, Downloads, Desktop, etc.), so we wait for explicit navigation. - useEffect(() => { - if (!isBrowsing || filteredBrowseEntries.length === 0) return; - - if (canNavigateUp(query)) { - prefetchBrowsePath(getBrowseParentPath(query)!); - } - }, [filteredBrowseEntries.length, isBrowsing, prefetchBrowsePath, query]); - const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { const latestThread = getLatestThreadForProject( @@ -633,7 +562,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -651,7 +580,7 @@ function OpenCommandPaletteDialog() { icon: (project) => ( ), @@ -659,7 +588,7 @@ function OpenCommandPaletteDialog() { await startNewThreadInProjectFromContext( { activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, @@ -867,42 +796,48 @@ function OpenCommandPaletteDialog() { (environmentId: EnvironmentId): void => { setAddProjectEnvironmentId(environmentId); setAddProjectCloneFlow(null); - const target = { environmentId }; - const initialDiscovery = getSourceControlDiscoverySnapshot(target).data; pushPaletteView({ addonIcon: , groups: buildAddProjectSourceGroups( environmentId, - buildAddProjectRemoteSourceReadiness(initialDiscovery), + buildAddProjectRemoteSourceReadiness( + browseEnvironmentId === environmentId ? sourceControlDiscovery.data : null, + ), ), }); - - if (initialDiscovery) { - return; - } - - void refreshSourceControlDiscovery(target).then((discovery) => { - setViewStack((previousViews) => { - const currentTopView = previousViews.at(-1); - if (currentTopView?.groups[0]?.value !== `sources:${environmentId}`) { - return previousViews; - } - return [ - ...previousViews.slice(0, -1), - { - addonIcon: , - groups: buildAddProjectSourceGroups( - environmentId, - buildAddProjectRemoteSourceReadiness(discovery), - ), - }, - ]; - }); - }); }, - [buildAddProjectSourceGroups], + [browseEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data], ); + useEffect(() => { + if (addProjectEnvironmentId === null) { + return; + } + sourceControlDiscovery.refresh(); + }, [addProjectEnvironmentId, sourceControlDiscovery.refresh]); + + useEffect(() => { + if (addProjectEnvironmentId === null || sourceControlDiscovery.data === null) { + return; + } + setViewStack((previousViews) => { + const currentTopView = previousViews.at(-1); + if (currentTopView?.groups[0]?.value !== `sources:${addProjectEnvironmentId}`) { + return previousViews; + } + return [ + ...previousViews.slice(0, -1), + { + addonIcon: , + groups: buildAddProjectSourceGroups( + addProjectEnvironmentId, + buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), + ), + }, + ]; + }); + }, [addProjectEnvironmentId, buildAddProjectSourceGroups, sourceControlDiscovery.data]); + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( (option) => ({ kind: "action", @@ -988,7 +923,7 @@ function OpenCommandPaletteDialog() { run: async () => { await startNewThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: settings.defaultThreadEnvMode, handleNewThread, @@ -1062,8 +997,6 @@ function OpenCommandPaletteDialog() { const handleAddProject = useCallback( async (rawCwd: string) => { if (!browseEnvironmentId) return; - const api = readEnvironmentApi(browseEnvironmentId); - if (!api) return; if (isUnsupportedWindowsProjectPath(rawCwd.trim(), browseEnvironmentPlatform)) { toastManager.add( @@ -1118,18 +1051,18 @@ function OpenCommandPaletteDialog() { try { const projectId = newProjectId(); - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title: inferProjectTitleFromPath(cwd), - workspaceRoot: cwd, - createWorkspaceRootIfMissing: true, - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, + await createProject({ + environmentId: browseEnvironmentId, + input: { + projectId, + title: inferProjectTitleFromPath(cwd), + workspaceRoot: cwd, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, }, - createdAt: new Date().toISOString(), }); await handleNewThread(scopeProjectRef(browseEnvironmentId, projectId), { envMode: settings.defaultThreadEnvMode, @@ -1150,6 +1083,7 @@ function OpenCommandPaletteDialog() { browseEnvironmentPlatform, currentProjectCwdForBrowse, handleNewThread, + createProject, navigate, projects, setOpen, @@ -1168,18 +1102,6 @@ function OpenCommandPaletteDialog() { return; } - const api = readEnvironmentApi(addProjectCloneFlow.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to clone project", - description: "Environment API is not available.", - }), - ); - return; - } - if (addProjectCloneFlow.step === "repository") { const rawRepository = query.trim(); if (rawRepository.length === 0 || isRemoteProjectLookingUp) { @@ -1205,9 +1127,12 @@ function OpenCommandPaletteDialog() { setIsRemoteProjectLookingUp(true); try { - const repository = await api.sourceControl.lookupRepository({ - provider, - repository: rawRepository, + const repository = await lookupRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + provider, + repository: rawRepository, + }, }); const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); setAddProjectCloneFlow({ @@ -1272,9 +1197,12 @@ function OpenCommandPaletteDialog() { setIsRemoteProjectCloning(true); try { - const result = await api.sourceControl.cloneRepository({ - remoteUrl: addProjectCloneFlow.remoteUrl, - destinationPath, + const result = await cloneRepository({ + environmentId: addProjectCloneFlow.environmentId, + input: { + remoteUrl: addProjectCloneFlow.remoteUrl, + destinationPath, + }, }); await handleAddProject(result.cwd); } catch (error) { diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 4df5502394e..2fcbd929e17 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -910,6 +910,12 @@ function ComposerCommandKeyPlugin(props: { if (!props.onCommandKeyDown || !event) { return false; } + + if (key === "Enter" && (event.isComposing || event.keyCode === 229)) { + event.stopPropagation(); + return true; + } + const handled = props.onCommandKeyDown(key, event); if (handled) { event.preventDefault(); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c6d00d4bece..9f97dc61c8e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,6 +1,6 @@ import { FileDiff, Virtualizer } from "@pierre/diffs/react"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { TurnId } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -19,11 +19,9 @@ import { useRef, useState, } from "react"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; -import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; -import { readLocalApi } from "../localApi"; import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { useTheme } from "../hooks/useTheme"; @@ -35,14 +33,15 @@ import { resolveFileDiffPath, } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; -import { selectProjectByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; +import { useProject, useThreadDetail } from "../state/entities"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { useSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { vcsEnvironment } from "../state/vcs"; type DiffRenderMode = "stacked" | "split"; type DiffThemeType = "light" | "dark"; @@ -53,8 +52,6 @@ const DIFF_PANEL_UNSAFE_CSS = ` [data-file], [data-error-wrapper], [data-virtualizer-buffer] { - --diffs-header-font-family: var(--font-sans) !important; - --diffs-font-family: var(--font-mono) !important; --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important; @@ -95,37 +92,6 @@ const DIFF_PANEL_UNSAFE_CSS = ` z-index: 4; background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important; border-bottom: 1px solid var(--border) !important; - align-items: center !important; - font-family: var(--font-sans) !important; - font-size: 12px !important; - line-height: 1 !important; - min-height: 32px !important; - padding-block: 6px !important; -} - -[data-diffs-header] [data-header-content] { - align-items: center !important; - line-height: 1 !important; -} - -[data-diffs-header] [data-metadata] { - align-items: center !important; - line-height: 1 !important; - font-variant-numeric: tabular-nums; -} - -[data-diffs-header] [data-additions-count], -[data-diffs-header] [data-deletions-count] { - font-family: var(--font-mono) !important; - font-size: 11px !important; - font-variant-numeric: tabular-nums; - line-height: 1 !important; -} - -[data-diffs-header] [data-change-icon], -[data-diffs-header] [data-rename-icon] { - display: block; - flex-shrink: 0; } [data-title] { @@ -136,7 +102,6 @@ const DIFF_PANEL_UNSAFE_CSS = ` text-decoration: underline; text-decoration-color: transparent; text-underline-offset: 2px; - font-family: var(--font-sans) !important; } [data-title]:hover { @@ -173,23 +138,37 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const diffSearch = useSearch({ strict: false, select: (search) => parseDiffRouteSearch(search) }); const diffOpen = diffSearch.diff === "1"; const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), - ); + const activeThread = useThreadDetail(routeThreadRef); const activeProjectId = activeThread?.projectId ?? null; - const activeProject = useStore((store) => + const activeProject = useProject( activeThread && activeProjectId - ? selectProjectByRef(store, { + ? { environmentId: activeThread.environmentId, projectId: activeProjectId, + } + : null, + ); + const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const serverConfig = useEnvironmentQuery( + activeThread === null || activeThread === undefined + ? null + : serverEnvironment.config({ + environmentId: activeThread.environmentId, + input: {}, + }), + ); + const openInPreferredEditor = useOpenInPreferredEditor( + activeThread?.environmentId ?? null, + serverConfig.data?.availableEditors ?? [], + ); + const gitStatusQuery = useEnvironmentQuery( + activeThread !== null && activeThread !== undefined && activeCwd != null + ? vcsEnvironment.status({ + environmentId: activeThread.environmentId, + input: { cwd: activeCwd }, }) - : undefined, + : null, ); - const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useVcsStatus({ - environmentId: activeThread?.environmentId ?? null, - cwd: activeCwd ?? null, - }); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -330,14 +309,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const openDiffFileInEditor = useCallback( (filePath: string) => { - const api = readLocalApi(); - if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void openInPreferredEditor(api, targetPath).catch((error) => { + void openInPreferredEditor(targetPath).catch((error) => { console.warn("Failed to open diff file in editor.", error); }); }, - [activeCwd], + [activeCwd, openInPreferredEditor], ); const toggleDiffFileCollapsed = useCallback((fileKey: string) => { setCollapsedDiffFileKeys((current) => { @@ -495,41 +472,35 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { {orderedTurnDiffSummaries.map((summary) => ( - - selectTurn(summary.turnId)} - data-turn-chip-selected={summary.turnId === selectedTurn?.turnId} - /> - } + ))} @@ -553,50 +524,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { - - { - setDiffWordWrap(Boolean(pressed)); - }} - /> - } - > - - - - {diffWordWrap ? "Disable line wrapping" : "Enable line wrapping"} - - - - { - setDiffIgnoreWhitespace(Boolean(pressed)); - }} - /> - } - > - - - - {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"} - - + { + setDiffWordWrap(Boolean(pressed)); + }} + > + + + { + setDiffIgnoreWhitespace(Boolean(pressed)); + }} + > + + ); @@ -670,36 +621,26 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - - )} - - - {collapsed ? "Expand diff" : "Collapse diff"} - - + )} options={{ collapsed, diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 829ed4159d4..6a2219d610f 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -5,10 +5,10 @@ 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 @@ -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 (
() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - - const promise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - - return { promise, resolve, reject }; -} - -const { - activeRunStackedActionDeferredRef, - activeDraftThreadRef, - hasServerThreadRef, - invalidateSourceControlStateSpy, - refreshVcsStatusSpy, - runStackedActionSpy, - setDraftThreadContextSpy, - setThreadBranchSpy, - toastAddSpy, - toastCloseSpy, - toastPromiseSpy, - toastUpdateSpy, -} = vi.hoisted(() => ({ - activeRunStackedActionDeferredRef: { current: createDeferredPromise() }, - activeDraftThreadRef: { current: null as unknown }, - hasServerThreadRef: { current: true }, - invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()), - refreshVcsStatusSpy: vi.fn(() => Promise.resolve(null)), - runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise), - setDraftThreadContextSpy: vi.fn(), - setThreadBranchSpy: vi.fn(), - toastAddSpy: vi.fn(() => "toast-1"), - toastCloseSpy: vi.fn(), - toastPromiseSpy: vi.fn(), - toastUpdateSpy: vi.fn(), -})); - -vi.mock("~/components/ui/toast", () => ({ - toastManager: { - add: toastAddSpy, - close: toastCloseSpy, - promise: toastPromiseSpy, - update: toastUpdateSpy, - }, - stackedThreadToast: vi.fn((options: unknown) => options), -})); - -vi.mock("~/editorPreferences", () => ({ - openInPreferredEditor: vi.fn(), -})); - -vi.mock("~/lib/sourceControlActions", () => ({ - invalidateSourceControlState: invalidateSourceControlStateSpy, - useGitStackedAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: runStackedActionSpy, - })), - useSourceControlActionRunning: vi.fn(() => false), - useSourceControlPublishRepositoryAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: vi.fn(), - })), - useVcsInitAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: vi.fn(), - })), - useVcsPullAction: vi.fn(() => ({ - error: null, - isPending: false, - resetError: vi.fn(), - run: vi.fn(), - })), -})); - -vi.mock("~/lib/vcsStatusState", () => ({ - refreshVcsStatus: refreshVcsStatusSpy, - resetVcsStatusStateForTests: () => undefined, - useVcsStatus: vi.fn(() => ({ - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: BRANCH_NAME, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 1, - behindCount: 0, - pr: null, - }, - error: null, - isPending: false, - })), -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => { - throw new Error("ensureLocalApi not implemented in browser test"); - }), - readLocalApi: vi.fn(() => null), -})); - -vi.mock("~/composerDraftStore", async () => { - const draftStoreState = { - getDraftThreadByRef: () => activeDraftThreadRef.current, - getDraftSession: () => activeDraftThreadRef.current, - getDraftThread: () => activeDraftThreadRef.current, - getDraftSessionByLogicalProjectKey: () => null, - setDraftThreadContext: setDraftThreadContextSpy, - setLogicalProjectDraftThreadId: vi.fn(), - setProjectDraftThreadId: vi.fn(), - hasDraftThreadsInEnvironment: () => false, - clearDraftThread: vi.fn(), - }; - - return { - DraftId: { - makeUnsafe: (value: string) => value, - }, - useComposerDraftStore: Object.assign( - (selector: (state: unknown) => unknown) => selector(draftStoreState), - { getState: () => draftStoreState }, - ), - markPromotedDraftThread: vi.fn(), - markPromotedDraftThreadByRef: vi.fn(), - markPromotedDraftThreads: vi.fn(), - markPromotedDraftThreadsByRef: vi.fn(), - finalizePromotedDraftThreadByRef: vi.fn(), - finalizePromotedDraftThreadsByRef: vi.fn(), - }; -}); - -vi.mock("~/store", () => ({ - selectEnvironmentState: ( - state: { environmentStateById: Record }, - environmentId: string | null, - ) => { - if (!environmentId) { - throw new Error("Missing environment id"); - } - const environmentState = state.environmentStateById[environmentId]; - if (!environmentState) { - throw new Error(`Unknown environment: ${environmentId}`); - } - return environmentState; - }, - selectProjectsForEnvironment: () => [], - selectProjectsAcrossEnvironments: () => [], - selectThreadsForEnvironment: () => [], - selectThreadsAcrossEnvironments: () => [], - selectThreadShellsAcrossEnvironments: () => [], - selectSidebarThreadsAcrossEnvironments: () => [], - selectSidebarThreadsForProjectRef: () => [], - selectSidebarThreadsForProjectRefs: () => [], - selectBootstrapCompleteForActiveEnvironment: () => true, - selectProjectByRef: () => null, - selectThreadByRef: () => null, - selectSidebarThreadSummaryByRef: () => null, - selectThreadIdsByProjectRef: () => [], - useStore: (selector: (state: unknown) => unknown) => - selector({ - setThreadBranch: setThreadBranchSpy, - environmentStateById: { - [ENVIRONMENT_A]: { - threadShellById: hasServerThreadRef.current - ? { - [SHARED_THREAD_ID]: { - id: SHARED_THREAD_ID, - branch: BRANCH_NAME, - worktreePath: null, - }, - } - : {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - }, - [ENVIRONMENT_B]: { - threadShellById: hasServerThreadRef.current - ? { - [SHARED_THREAD_ID]: { - id: SHARED_THREAD_ID, - branch: BRANCH_NAME, - worktreePath: null, - }, - } - : {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - }, - }, - }), -})); - -vi.mock("~/terminal-links", () => ({ - resolvePathLinkTarget: vi.fn(), -})); - -import GitActionsControl from "./GitActionsControl"; - -function findButtonByText(text: string): HTMLButtonElement | null { - return (Array.from(document.querySelectorAll("button")).find((button) => - button.textContent?.includes(text), - ) ?? null) as HTMLButtonElement | null; -} - -function Harness() { - const [activeThreadRef, setActiveThreadRef] = useState( - scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), - ); - - return ( - <> - - - - ); -} - -describe("GitActionsControl thread-scoped progress toast", () => { - afterEach(() => { - vi.useRealTimers(); - vi.clearAllMocks(); - activeRunStackedActionDeferredRef.current = createDeferredPromise(); - activeDraftThreadRef.current = null; - hasServerThreadRef.current = true; - document.body.innerHTML = ""; - }); - - it("keeps an in-flight git action toast pinned to the thread ref that started it", async () => { - vi.useFakeTimers(); - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render(, { container: host }); - - try { - const quickActionButton = findButtonByText("Push & create PR"); - expect(quickActionButton, 'Unable to find button containing "Push & create PR"').toBeTruthy(); - if (!(quickActionButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Push & create PR"'); - } - quickActionButton.click(); - - expect(toastAddSpy).toHaveBeenCalledWith( - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - - await vi.advanceTimersByTimeAsync(1_000); - - expect(toastUpdateSpy).toHaveBeenLastCalledWith( - "toast-1", - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - - const switchEnvironmentButton = findButtonByText("Switch environment"); - expect( - switchEnvironmentButton, - 'Unable to find button containing "Switch environment"', - ).toBeTruthy(); - if (!(switchEnvironmentButton instanceof HTMLButtonElement)) { - throw new Error('Unable to find button containing "Switch environment"'); - } - switchEnvironmentButton.click(); - await vi.advanceTimersByTimeAsync(1_000); - - expect(toastUpdateSpy).toHaveBeenLastCalledWith( - "toast-1", - expect.objectContaining({ - data: { threadRef: scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID) }, - title: "Pushing...", - type: "loading", - }), - ); - } finally { - activeRunStackedActionDeferredRef.current.reject(new Error("test cleanup")); - await Promise.resolve(); - vi.useRealTimers(); - await screen.unmount(); - host.remove(); - } - }); - - it("debounces focus-driven git status refreshes", async () => { - vi.useFakeTimers(); - - const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); - let visibilityState: DocumentVisibilityState = "hidden"; - Object.defineProperty(document, "visibilityState", { - configurable: true, - get: () => visibilityState, - }); - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - window.dispatchEvent(new Event("focus")); - visibilityState = "visible"; - document.dispatchEvent(new Event("visibilitychange")); - - expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(249); - expect(refreshVcsStatusSpy).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(1); - expect(refreshVcsStatusSpy).toHaveBeenCalledTimes(1); - expect(refreshVcsStatusSpy).toHaveBeenCalledWith({ - environmentId: ENVIRONMENT_A, - cwd: GIT_CWD, - }); - } finally { - if (originalVisibilityState) { - Object.defineProperty(document, "visibilityState", originalVisibilityState); - } - vi.useRealTimers(); - await screen.unmount(); - host.remove(); - } - }); - - it("syncs the live branch into the active draft thread when no server thread exists", async () => { - hasServerThreadRef.current = false; - activeDraftThreadRef.current = { - threadId: SHARED_THREAD_ID, - environmentId: ENVIRONMENT_A, - branch: null, - worktreePath: null, - }; - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - await Promise.resolve(); - - expect(setDraftThreadContextSpy).toHaveBeenCalledWith( - scopeThreadRef(ENVIRONMENT_A, SHARED_THREAD_ID), - { - branch: BRANCH_NAME, - worktreePath: null, - }, - ); - expect(setThreadBranchSpy).not.toHaveBeenCalled(); - } finally { - await screen.unmount(); - host.remove(); - } - }); - - it("does not overwrite a selected base branch while a new worktree draft is being configured", async () => { - hasServerThreadRef.current = false; - activeDraftThreadRef.current = { - threadId: SHARED_THREAD_ID, - environmentId: ENVIRONMENT_A, - branch: "feature/base-branch", - worktreePath: null, - envMode: "worktree", - }; - - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - , - { - container: host, - }, - ); - - try { - await Promise.resolve(); - - expect(setDraftThreadContextSpy).not.toHaveBeenCalled(); - expect(setThreadBranchSpy).not.toHaveBeenCalled(); - } finally { - await screen.unmount(); - host.remove(); - } - }); -}); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6a8dc53fbca..9cd8c22a09b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -63,7 +63,7 @@ import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { openInPreferredEditor } from "~/editorPreferences"; +import { useOpenInPreferredEditor } from "~/editorPreferences"; import { useGitStackedAction, useSourceControlActionRunning, @@ -71,16 +71,18 @@ import { useVcsInitAction, useVcsPullAction, } from "~/lib/sourceControlActions"; -import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; -import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; -import { newCommandId, randomUUID } from "~/lib/utils"; +import { useThreadDetail } from "~/state/entities"; +import { useEnvironmentQuery } from "~/state/query"; +import { serverEnvironment } from "~/state/server"; +import { sourceControlEnvironment } from "~/state/sourceControl"; +import { threadEnvironment } from "~/state/threads"; +import { vcsEnvironment } from "~/state/vcs"; +import { useAtomSet } from "@effect/atom-react"; +import { randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; -import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; -import { useStore } from "~/store"; -import { createThreadSelectorByRef } from "~/storeSelectors"; interface GitActionsControlProps { gitCwd: string | null; @@ -348,7 +350,14 @@ interface PublishRepositoryDialogProps { function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const navigate = useNavigate(); - const sourceControlDiscovery = useSourceControlDiscovery(); + const sourceControlDiscovery = useEnvironmentQuery( + props.environmentId === null + ? null + : sourceControlEnvironment.discovery({ + environmentId: props.environmentId, + input: {}, + }), + ); const [publishProvider, setPublishProvider] = useState("github"); const [publishRepository, setPublishRepository] = useState(""); const [publishVisibility, setPublishVisibility] = @@ -479,9 +488,6 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { setPublishResult(result); setPublishWizardStep(2); }); - void refreshVcsStatus({ environmentId: props.environmentId, cwd: props.gitCwd }).catch( - () => undefined, - ); }) .catch((err: unknown) => { setPublishError(err instanceof Error ? err.message : "An error occurred."); @@ -951,16 +957,24 @@ export default function GitActionsControl({ activeThreadRef, draftId, }: GitActionsControlProps) { + const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, { + mode: "promise", + }); const activeEnvironmentId = activeThreadRef?.environmentId ?? null; + const serverConfig = useEnvironmentQuery( + activeEnvironmentId === null + ? null + : serverEnvironment.config({ environmentId: activeEnvironmentId, input: {} }), + ); + const openInPreferredEditor = useOpenInPreferredEditor( + activeEnvironmentId, + serverConfig.data?.availableEditors ?? [], + ); const threadToastData = useMemo( () => (activeThreadRef ? { threadRef: activeThreadRef } : undefined), [activeThreadRef], ); - const activeServerThreadSelector = useMemo( - () => createThreadSelectorByRef(activeThreadRef), - [activeThreadRef], - ); - const activeServerThread = useStore(activeServerThreadSelector); + const activeServerThread = useThreadDetail(activeThreadRef); const activeDraftThread = useComposerDraftStore((store) => draftId ? store.getDraftSession(draftId) @@ -969,7 +983,6 @@ export default function GitActionsControl({ : null, ); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); - const setThreadBranch = useStore((store) => store.setThreadBranch); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); @@ -1010,20 +1023,15 @@ export default function GitActionsControl({ } const worktreePath = activeServerThread.worktreePath; - const api = readEnvironmentApi(activeThreadRef.environmentId); - if (api) { - void api.orchestration - .dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: activeThreadRef.threadId, - branch, - worktreePath, - }) - .catch(() => undefined); - } + void updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + branch, + worktreePath, + }, + }).catch(() => undefined); - setThreadBranch(activeThreadRef, branch, worktreePath); return; } @@ -1042,7 +1050,7 @@ export default function GitActionsControl({ activeThreadRef, draftId, setDraftThreadContext, - setThreadBranch, + updateThreadMetadata, ], ); @@ -1058,10 +1066,15 @@ export default function GitActionsControl({ [persistThreadBranchSync], ); - const { data: gitStatus, error: gitStatusError } = useVcsStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }); + const gitStatusQuery = useEnvironmentQuery( + activeEnvironmentId !== null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: activeEnvironmentId, + input: { cwd: gitCwd }, + }) + : null, + ); + const { data: gitStatus, error: gitStatusError } = gitStatusQuery; const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -1163,9 +1176,7 @@ export default function GitActionsControl({ } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - void refreshVcsStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( - () => undefined, - ); + gitStatusQuery.refresh(); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { @@ -1184,7 +1195,7 @@ export default function GitActionsControl({ window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [activeEnvironmentId, gitCwd]); + }, [gitCwd, gitStatusQuery.refresh]); const openExistingPr = useCallback(async () => { const api = readLocalApi(); @@ -1573,8 +1584,7 @@ export default function GitActionsControl({ const openChangedFileInEditor = useCallback( (filePath: string) => { - const api = readLocalApi(); - if (!api || !gitCwd) { + if (!gitCwd) { toastManager.add({ type: "error", title: "Editor opening is unavailable.", @@ -1583,7 +1593,7 @@ export default function GitActionsControl({ return; } const target = resolvePathLinkTarget(filePath, gitCwd); - void openInPreferredEditor(api, target).catch((error) => { + void openInPreferredEditor(target).catch((error) => { toastManager.add( stackedThreadToast({ type: "error", @@ -1594,7 +1604,7 @@ export default function GitActionsControl({ ); }); }, - [gitCwd, threadToastData], + [gitCwd, openInPreferredEditor, threadToastData], ); const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; @@ -1661,10 +1671,7 @@ export default function GitActionsControl({ { if (open) { - void refreshVcsStatus({ - environmentId: activeEnvironmentId, - cwd: gitCwd, - }).catch(() => undefined); + gitStatusQuery.refresh(); } }} > @@ -1745,7 +1752,7 @@ export default function GitActionsControl({

)} {gitStatusError && ( -

{gitStatusError.message}

+

{gitStatusError}

)}
diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx deleted file mode 100644 index b7aa6d7a645..00000000000 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ /dev/null @@ -1,636 +0,0 @@ -import "../index.css"; - -import { - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - ORCHESTRATION_WS_METHODS, - type MessageId, - type OrchestrationReadModel, - type ProjectId, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type ServerLifecycleWelcomePayload, - ServerConfig as ServerConfigSchema, - ServerSettings, - type ThreadId, - WS_METHODS, -} from "@t3tools/contracts"; -import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; -import { ws, http, HttpResponse } from "msw"; -import { setupWorker } from "msw/browser"; -import * as Schema from "effect/Schema"; -import { - afterAll, - afterEach, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vite-plus/test"; -import { render } from "vitest-browser-react"; - -import { useComposerDraftStore } from "../composerDraftStore"; -import { __resetLocalApiForTests } from "../localApi"; -import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; -import { getServerConfig, getServerConfigUpdatedNotification } from "../rpc/serverState"; -import { getWsConnectionStatus } from "../rpc/wsConnectionState"; -import { getRouter } from "../router"; -import { useStore } from "../store"; -import { createAuthenticatedSessionHandlers } from "../../test/authHttpHandlers"; -import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; - -vi.mock("../lib/vcsStatusState", () => { - const status = { - data: { - isRepo: true, - sourceControlProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasPrimaryRemote: true, - isDefaultRef: true, - refName: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }, - error: null, - cause: null, - isPending: false, - }; - - return { - getVcsStatusSnapshot: () => status, - useVcsStatus: () => status, - useVcsStatuses: () => new Map(), - refreshVcsStatus: () => Promise.resolve(null), - resetVcsStatusStateForTests: () => undefined, - }; -}); - -const THREAD_ID = "thread-kb-toast-test" as ThreadId; -const PROJECT_ID = "project-1" as ProjectId; -const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); -const NOW_ISO = "2026-03-04T12:00:00.000Z"; - -interface TestFixture { - snapshot: OrchestrationReadModel; - serverConfig: ServerConfig; - welcome: ServerLifecycleWelcomePayload; -} - -let fixture: TestFixture; -const rpcHarness = new BrowserWsRpcHarness(); -const encodeServerConfig = Schema.encodeSync(ServerConfigSchema); -const encodeServerSettings = Schema.encodeSync(ServerSettings); - -const wsLink = ws.link(/ws(s)?:\/\/.*/); - -function createBaseServerConfig(): ServerConfig { - return { - environment: { - environmentId: LOCAL_ENVIRONMENT_ID, - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/repo/project", - keybindingsConfigPath: "/repo/project/.t3code-keybindings.json", - keybindings: [], - issues: [], - providers: [ - { - driver: ProviderDriverKind.make("codex"), - instanceId: ProviderInstanceId.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: NOW_ISO, - models: [], - slashCommands: [], - skills: [], - }, - ], - availableEditors: [], - observability: { - logsDirectoryPath: "/repo/project/.t3/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: false, - defaultThreadEnvMode: "local" as const, - textGenerationModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4-mini", - }, - providers: { - codex: { - enabled: true, - binaryPath: "", - homePath: "", - shadowHomePath: "", - customModels: [], - }, - claudeAgent: { - enabled: true, - binaryPath: "", - homePath: "", - customModels: [], - launchArgs: "", - }, - cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, - grok: { enabled: true, binaryPath: "", customModels: [] }, - opencode: { - enabled: true, - binaryPath: "", - serverUrl: "", - serverPassword: "", - customModels: [], - }, - }, - }, - }; -} - -function createMinimalSnapshot(): OrchestrationReadModel { - return { - snapshotSequence: 1, - projects: [ - { - id: PROJECT_ID, - title: "Project", - workspaceRoot: "/repo/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - scripts: [], - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - deletedAt: null, - }, - ], - threads: [ - { - id: THREAD_ID, - projectId: PROJECT_ID, - title: "Test thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5", - }, - interactionMode: "default", - runtimeMode: "full-access", - branch: "main", - worktreePath: null, - latestTurn: null, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - archivedAt: null, - deletedAt: null, - messages: [ - { - id: "msg-1" as MessageId, - role: "user", - text: "hello", - turnId: null, - streaming: false, - createdAt: NOW_ISO, - updatedAt: NOW_ISO, - }, - ], - activities: [], - proposedPlans: [], - checkpoints: [], - session: { - threadId: THREAD_ID, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW_ISO, - }, - }, - ], - updatedAt: NOW_ISO, - }; -} - -function toShellSnapshot(snapshot: OrchestrationReadModel) { - return { - snapshotSequence: snapshot.snapshotSequence, - projects: snapshot.projects.map((project) => ({ - id: project.id, - title: project.title, - workspaceRoot: project.workspaceRoot, - repositoryIdentity: project.repositoryIdentity ?? null, - defaultModelSelection: project.defaultModelSelection, - scripts: project.scripts, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - })), - threads: snapshot.threads.map((thread) => ({ - id: thread.id, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestTurn: thread.latestTurn, - createdAt: thread.createdAt, - updatedAt: thread.updatedAt, - archivedAt: thread.archivedAt, - session: thread.session, - latestUserMessageAt: - thread.messages.findLast((message) => message.role === "user")?.createdAt ?? null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - })), - updatedAt: snapshot.updatedAt, - }; -} - -function buildFixture(): TestFixture { - return { - snapshot: createMinimalSnapshot(), - serverConfig: createBaseServerConfig(), - welcome: { - environment: { - environmentId: LOCAL_ENVIRONMENT_ID, - label: "Local environment", - platform: { os: "darwin" as const, arch: "arm64" as const }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/repo/project", - projectName: "Project", - bootstrapProjectId: PROJECT_ID, - bootstrapThreadId: THREAD_ID, - }, - }; -} - -function resolveWsRpc(tag: string): unknown { - if (tag === WS_METHODS.serverGetConfig) { - return encodeServerConfig(fixture.serverConfig); - } - if (tag === WS_METHODS.vcsListRefs) { - return { - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 1, - refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], - }; - } - if (tag === WS_METHODS.projectsSearchEntries) { - return { entries: [], truncated: false }; - } - return {}; -} - -const worker = setupWorker( - wsLink.addEventListener("connection", ({ client }) => { - void rpcHarness.connect(client); - client.addEventListener("message", (event) => { - const rawData = event.data; - if (typeof rawData !== "string") return; - void rpcHarness.onMessage(rawData); - }); - }), - ...createAuthenticatedSessionHandlers(() => fixture.serverConfig.auth), - http.get("*/attachments/:attachmentId", () => new HttpResponse(null, { status: 204 })), - http.get("*/api/project-favicon", () => new HttpResponse(null, { status: 204 })), -); - -function sendServerConfigUpdatedPush(issues: ServerConfig["issues"]) { - rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { - version: 1, - type: "keybindingsUpdated", - payload: { keybindings: fixture.serverConfig.keybindings, issues }, - }); -} - -function queryToastTitles(): string[] { - return Array.from(document.querySelectorAll('[data-slot="toast-title"]')).map( - (el) => el.textContent ?? "", - ); -} - -async function waitForElement( - query: () => T | null, - errorMessage: string, -): Promise { - let element: T | null = null; - await vi.waitFor( - () => { - element = query(); - expect(element, errorMessage).toBeTruthy(); - }, - { timeout: 8_000, interval: 16 }, - ); - return element!; -} - -async function waitForComposerEditor(): Promise { - return waitForElement( - () => document.querySelector('[data-testid="composer-editor"]'), - "App should render composer editor", - ); -} - -async function waitForToastViewport(): Promise { - return waitForElement( - () => document.querySelector('[data-slot="toast-viewport"]'), - "App should render the toast viewport before server config updates are pushed", - ); -} - -async function waitForWsConnection(): Promise { - await vi.waitFor( - () => { - expect(getWsConnectionStatus().phase).toBe("connected"); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForToast(title: string, count = 1): Promise { - await vi.waitFor( - () => { - const matches = queryToastTitles().filter((t) => t === title); - expect(matches.length, `Expected ${count} "${title}" toast(s)`).toBeGreaterThanOrEqual(count); - }, - { timeout: 4_000, interval: 16 }, - ); -} - -async function waitForNoToast(title: string): Promise { - await vi.waitFor( - () => { - expect(queryToastTitles().filter((t) => t === title)).toHaveLength(0); - }, - { timeout: 10_000, interval: 50 }, - ); -} - -async function waitForNoToasts(): Promise { - await vi.waitFor( - () => { - expect(queryToastTitles()).toHaveLength(0); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForInitialWsSubscriptions(): Promise { - await vi.waitFor( - () => { - expect( - rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), - ).toBe(true); - expect( - rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), - ).toBe(true); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForServerConfigSnapshot(): Promise { - await vi.waitFor( - () => { - expect(getServerConfig()).not.toBeNull(); - }, - { timeout: 8_000, interval: 16 }, - ); -} - -async function waitForServerConfigStreamReady(): Promise { - const previousNotificationId = getServerConfigUpdatedNotification()?.id ?? 0; - for (let attempt = 0; attempt < 20; attempt += 1) { - rpcHarness.emitStreamValue(WS_METHODS.subscribeServerConfig, { - version: 1, - type: "settingsUpdated", - payload: { settings: encodeServerSettings(fixture.serverConfig.settings) }, - }); - - try { - await vi.waitFor( - () => { - const notification = getServerConfigUpdatedNotification(); - expect(notification?.id).toBeGreaterThan(previousNotificationId); - expect(notification?.source).toBe("settingsUpdated"); - }, - { timeout: 200, interval: 16 }, - ); - return; - } catch { - await new Promise((resolve) => setTimeout(resolve, 25)); - } - } - - throw new Error("Timed out waiting for the server config stream to deliver updates."); -} - -async function mountApp(): Promise<{ cleanup: () => Promise }> { - const host = document.createElement("div"); - host.style.position = "fixed"; - host.style.inset = "0"; - host.style.width = "100vw"; - host.style.height = "100vh"; - host.style.display = "grid"; - host.style.overflow = "hidden"; - document.body.append(host); - - const router = getRouter( - createMemoryHistory({ initialEntries: [`/${LOCAL_ENVIRONMENT_ID}/${THREAD_ID}`] }), - ); - - const screen = await render( - - - , - { container: host }, - ); - await waitForComposerEditor(); - await waitForToastViewport(); - await waitForInitialWsSubscriptions(); - await waitForWsConnection(); - await waitForServerConfigSnapshot(); - await waitForServerConfigStreamReady(); - await waitForNoToasts(); - - return { - cleanup: async () => { - await screen.unmount(); - host.remove(); - }, - }; -} - -describe("Keybindings update toast", () => { - beforeAll(async () => { - fixture = buildFixture(); - await worker.start({ - onUnhandledRequest: "bypass", - quiet: true, - serviceWorker: { url: "/mockServiceWorker.js" }, - }); - }); - - afterAll(async () => { - await rpcHarness.disconnect(); - await worker.stop(); - }); - - beforeEach(async () => { - await rpcHarness.reset({ - resolveUnary: (request) => resolveWsRpc(request._tag), - getInitialStreamValues: (request) => { - if (request._tag === WS_METHODS.subscribeServerLifecycle) { - return [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: fixture.welcome, - }, - ]; - } - if (request._tag === WS_METHODS.subscribeServerConfig) { - return [ - { - version: 1, - type: "snapshot", - config: encodeServerConfig(fixture.serverConfig), - }, - ]; - } - if (request._tag === ORCHESTRATION_WS_METHODS.subscribeShell) { - return [ - { - kind: "snapshot", - snapshot: toShellSnapshot(fixture.snapshot), - }, - ]; - } - if ( - request._tag === ORCHESTRATION_WS_METHODS.subscribeThread && - request.threadId === THREAD_ID - ) { - return [ - { - kind: "snapshot", - snapshot: { - snapshotSequence: fixture.snapshot.snapshotSequence, - thread: fixture.snapshot.threads[0], - }, - }, - ]; - } - return []; - }, - }); - await __resetLocalApiForTests(); - localStorage.clear(); - document.body.innerHTML = ""; - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - }); - useStore.setState({ - activeEnvironmentId: null, - environmentStateById: {}, - }); - }); - - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("coalesces rapid consecutive keybinding update toasts with no issues", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated", 1); - - // A single edit can produce several reload notifications as the direct update and - // filesystem watcher settle, so avoid stacking identical success toasts. - sendServerConfigUpdatedPush([]); - await new Promise((resolve) => setTimeout(resolve, 250)); - - const titles = queryToastTitles(); - expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1); - } finally { - await mounted.cleanup(); - } - }); - - it("shows a warning toast when keybinding config has issues", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([ - { kind: "keybindings.malformed-config", message: "Expected JSON array" }, - ]); - await waitForToast("Invalid keybindings configuration"); - } finally { - await mounted.cleanup(); - } - }); - - it("does not show a toast from the replayed cached value on subscribe", async () => { - const mounted = await mountApp(); - - try { - sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated"); - await waitForNoToast("Keybindings updated"); - - // Remount the app — onServerConfigUpdated replays the cached value - // synchronously on subscribe. This should NOT produce a toast. - await mounted.cleanup(); - const remounted = await mountApp(); - - // Give it a moment to process the replayed value - await new Promise((resolve) => setTimeout(resolve, 500)); - - const titles = queryToastTitles(); - expect( - titles.filter((t) => t === "Keybindings updated").length, - "Replayed cached value should not produce a toast", - ).toBe(0); - - await remounted.cleanup(); - } catch (error) { - await mounted.cleanup().catch(() => {}); - throw error; - } - }); -}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts new file mode 100644 index 00000000000..de5a2123cde --- /dev/null +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.test.ts @@ -0,0 +1,73 @@ +import type { ServerConfigStreamEvent } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createKeybindingsUpdateToastController, + KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS, +} from "./KeybindingsUpdateToast.logic"; + +function keybindingsEvent( + overrides: Partial> = {}, +): Extract { + return { + version: 1, + type: "keybindingsUpdated", + payload: { + keybindings: [], + issues: [], + }, + ...overrides, + }; +} + +describe("keybindings update toast policy", () => { + it("coalesces repeated successful reload notifications during the cooldown", () => { + let now = 1_000; + const controller = createKeybindingsUpdateToastController({ + now: () => now, + }); + + expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); + + now += KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS - 1; + expect(controller.handle(keybindingsEvent())).toBeNull(); + + now += 1; + expect(controller.handle(keybindingsEvent())).toEqual({ _tag: "Success" }); + }); + + it("surfaces keybinding configuration issues", () => { + const controller = createKeybindingsUpdateToastController({}); + + expect( + controller.handle( + keybindingsEvent({ + payload: { + keybindings: [], + issues: [ + { + kind: "keybindings.malformed-config", + message: "Expected JSON array", + }, + ], + }, + }), + ), + ).toEqual({ + _tag: "InvalidConfiguration", + message: "Expected JSON array", + }); + }); + + it("ignores unrelated server config notifications", () => { + const controller = createKeybindingsUpdateToastController({}); + + expect( + controller.handle({ + version: 1, + type: "settingsUpdated", + payload: { settings: {} as never }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/KeybindingsUpdateToast.logic.ts b/apps/web/src/components/KeybindingsUpdateToast.logic.ts new file mode 100644 index 00000000000..f6a47f50cfc --- /dev/null +++ b/apps/web/src/components/KeybindingsUpdateToast.logic.ts @@ -0,0 +1,45 @@ +import type { ServerConfigStreamEvent } from "@t3tools/contracts"; + +export const KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS = 2_000; + +export type KeybindingsUpdateToastDecision = + | { readonly _tag: "Success" } + | { readonly _tag: "InvalidConfiguration"; readonly message: string }; + +export interface KeybindingsUpdateToastController { + readonly handle: (event: ServerConfigStreamEvent | null) => KeybindingsUpdateToastDecision | null; +} + +export function createKeybindingsUpdateToastController(input: { + readonly now?: () => number; +}): KeybindingsUpdateToastController { + const now = input.now ?? Date.now; + let lastSuccessToastAt: number | null = null; + + return { + handle: (event) => { + if (event?.type !== "keybindingsUpdated") { + return null; + } + + const issue = event.payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); + if (issue) { + return { + _tag: "InvalidConfiguration", + message: issue.message, + }; + } + + const currentTime = now(); + if ( + lastSuccessToastAt !== null && + currentTime - lastSuccessToastAt < KEYBINDINGS_SUCCESS_TOAST_COOLDOWN_MS + ) { + return null; + } + + lastSuccessToastAt = currentTime; + return { _tag: "Success" }; + }, + }; +} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 62bd1ba89db..eb9a60055f7 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useState, useCallback } from "react"; +import { useAtomSet } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { Badge } from "./ui/badge"; @@ -25,7 +26,7 @@ import { stripDisplayedPlanMarkdown, } from "../proposedPlan"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; -import { readEnvironmentApi } from "~/environmentApi"; +import { projectEnvironment } from "~/state/projects"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -76,6 +77,7 @@ const PlanSidebar = memo(function PlanSidebar({ }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const writeProjectFile = useAtomSet(projectEnvironment.writeFile, { mode: "promise" }); const { copyToClipboard, isCopied } = useCopyToClipboard(); const planMarkdown = activeProposedPlan?.planMarkdown ?? null; @@ -94,16 +96,17 @@ const PlanSidebar = memo(function PlanSidebar({ }, [planMarkdown]); const handleSaveToWorkspace = useCallback(() => { - const api = readEnvironmentApi(environmentId); - if (!api || !workspaceRoot || !planMarkdown) return; + if (!workspaceRoot || !planMarkdown) return; const filename = buildProposedPlanMarkdownFilename(planMarkdown); setIsSavingToWorkspace(true); - void api.projects - .writeFile({ + void writeProjectFile({ + environmentId, + input: { cwd: workspaceRoot, relativePath: filename, contents: normalizePlanMarkdownForExport(planMarkdown), - }) + }, + }) .then((result) => { toastManager.add({ type: "success", @@ -124,7 +127,7 @@ const PlanSidebar = memo(function PlanSidebar({ () => setIsSavingToWorkspace(false), () => setIsSavingToWorkspace(false), ); - }, [environmentId, planMarkdown, workspaceRoot]); + }, [environmentId, planMarkdown, workspaceRoot, writeProjectFile]); return (
(); @@ -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..a0de3c8bd7f 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -85,10 +85,14 @@ 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 { - scripts: ProjectScript[]; + scripts: ReadonlyArray; keybindings: ResolvedKeybindingsConfig; preferredScriptId?: string | null; onRunScript: (script: ProjectScript) => void; @@ -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/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 69cd83bf8dc..cf75208b5b8 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -1,11 +1,12 @@ import { useNavigate } from "@tanstack/react-router"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { DownloadIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; -import { ensureLocalApi } from "../localApi"; +import { primaryServerProvidersAtom, serverEnvironment } from "../state/server"; +import { usePrimaryEnvironment } from "../state/environments"; import { useDismissedProviderUpdateNotificationKeys } from "../providerUpdateDismissal"; -import { useServerProviders } from "../rpc/serverState"; import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { canOneClickUpdateProviderCandidate, @@ -101,7 +102,9 @@ function isTerminalProviderUpdateToastView(view: ProviderUpdateToastView) { export function ProviderUpdateLaunchNotification() { const navigate = useNavigate(); - const providers = useServerProviders(); + const providers = useAtomValue(primaryServerProvidersAtom); + const primaryEnvironment = usePrimaryEnvironment(); + const updateProvider = useAtomSet(serverEnvironment.updateProvider, { mode: "promise" }); const activeToastRef = useRef(null); const { dismissedNotificationKeys, dismissNotificationKey } = useDismissedProviderUpdateNotificationKeys(); @@ -185,7 +188,7 @@ export function ProviderUpdateLaunchNotification() { }; const runUpdates = () => { - if (updateStarted || oneClickProviders.length === 0) { + if (updateStarted || oneClickProviders.length === 0 || !primaryEnvironment) { return; } updateStarted = true; @@ -208,9 +211,12 @@ export function ProviderUpdateLaunchNotification() { void Promise.allSettled( oneClickProviders.map(async (provider) => - ensureLocalApi().server.updateProvider({ - provider: provider.driver, - instanceId: provider.instanceId, + updateProvider({ + environmentId: primaryEnvironment.environmentId, + input: { + provider: provider.driver, + instanceId: provider.instanceId, + }, }), ), ).then((results) => { @@ -288,11 +294,13 @@ export function ProviderUpdateLaunchNotification() { ); activeToastRef.current = { kind: "prompt", key: notificationKey, toastId }; }, [ + updateProvider, dismissNotificationKey, dismissedNotificationKeys, notificationKey, oneClickProviders, openProviderSettings, + primaryEnvironment, updateProviders, ]); diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 688ea004f52..2b88c31167b 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -7,10 +7,11 @@ import { usePreparePullRequestThreadAction, usePullRequestResolution, } from "~/lib/sourceControlActions"; -import { useVcsStatus } from "~/lib/vcsStatusState"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { useEnvironmentQuery } from "~/state/query"; +import { vcsEnvironment } from "~/state/vcs"; import { Button } from "./ui/button"; import { Dialog, @@ -52,7 +53,14 @@ export function PullRequestThreadDialog({ { wait: 450 }, (debouncerState) => ({ isPending: debouncerState.isPending }), ); - const { data: gitStatus } = useVcsStatus({ environmentId, cwd }); + const { data: gitStatus } = useEnvironmentQuery( + cwd === null + ? null + : vcsEnvironment.status({ + environmentId, + input: { cwd }, + }), + ); const sourceControlPresentation = useMemo( () => getSourceControlPresentation(gitStatus?.sourceControlProvider), [gitStatus?.sourceControlProvider], @@ -173,9 +181,7 @@ export function PullRequestThreadDialog({ const errorMessage = validationMessage ?? (resolvedPullRequest === null && pullRequestResolution.error - ? pullRequestResolution.error instanceof Error - ? pullRequestResolution.error.message - : `Failed to resolve ${terminology.singular}.` + ? pullRequestResolution.error : preparePullRequestThreadAction.error instanceof Error ? preparePullRequestThreadAction.error.message : preparePullRequestThreadAction.error diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx new file mode 100644 index 00000000000..b4d5c9cbb45 --- /dev/null +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -0,0 +1,254 @@ +import type { PreviewSessionSnapshot } from "@t3tools/contracts"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { ClipboardList, FileDiff, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { isElectron } from "~/env"; +import type { RightPanelSurface } from "~/rightPanelStore"; +import { cn } from "~/lib/utils"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { faviconUrlForOrigin } from "~/lib/favicon"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; + +interface RightPanelTabsProps { + mode: PreviewPanelMode; + surfaces: readonly RightPanelSurface[]; + activeSurfaceId: string | null; + previewSessions: Readonly>; + terminalLabelsById: ReadonlyMap; + onActivate: (surface: RightPanelSurface) => void; + onCloseSurface: (surface: RightPanelSurface) => void; + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + browserAvailable: boolean; + diffAvailable: boolean; + children: ReactNode; +} + +function RightPanelEmptyState(props: { + onAddBrowser: () => void; + onAddTerminal: () => void; + onAddDiff: () => void; + browserAvailable: boolean; + diffAvailable: boolean; +}) { + const actions = [ + { + label: "Browser", + description: "Open a local app or URL.", + icon: Globe2, + available: props.browserAvailable, + onClick: props.onAddBrowser, + }, + { + label: "Terminal", + description: "Start a shell in this workspace.", + icon: TerminalSquare, + available: true, + onClick: props.onAddTerminal, + }, + { + label: "Diff", + description: "Review changes in this thread.", + icon: FileDiff, + available: props.diffAvailable, + onClick: props.onAddDiff, + }, + ] as const; + + return ( +
+
+
+

Open a surface

+

+ Choose what to show in the right panel. +

+
+
+ {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
+
+
+ ); +} + +function surfaceTitle( + surface: RightPanelSurface, + sessions: Readonly>, + terminalLabelsById: ReadonlyMap, +): string { + switch (surface.kind) { + case "diff": + return "Diff"; + 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, +}: { + surface: RightPanelSurface; + sessions: Readonly>; +}) { + 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 "terminal": + return ; + case "plan": + return ; + } +} + +export function RightPanelTabs(props: RightPanelTabsProps) { + const ownsDesktopTitleBar = isElectron && props.mode === "inline"; + + return ( + +
+
+ {props.surfaces.map((surface) => { + const active = surface.id === props.activeSurfaceId; + const title = surfaceTitle(surface, props.previewSessions, props.terminalLabelsById); + return ( +
+ + props.onActivate(surface)} + > + + {title} + + } + /> + {title} + + +
+ ); + })} +
+ + + + + + + + Browser + + + + Terminal + + + + Diff + + + +
+
+ {props.activeSurfaceId === null ? ( + + ) : ( + props.children + )} +
+
+ ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bdbbf6f8491..b73b13e6d24 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ProviderDriverKind } from "@t3tools/contracts"; - import { createThreadJumpHintVisibilityController, getSidebarThreadIdsToPrewarm, @@ -327,17 +325,17 @@ describe("orderItemsByPreferredIds", () => { { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-alpha"), - cwd: "/work/alpha", + workspaceRoot: "/work/alpha", }, { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-beta"), - cwd: "/work/beta", + workspaceRoot: "/work/beta", }, { environmentId: EnvironmentId.make("environment-local"), id: ProjectId.make("id-gamma"), - cwd: "/work/gamma", + workspaceRoot: "/work/gamma", }, ]; const ordered = orderItemsByPreferredIds({ @@ -346,7 +344,7 @@ describe("orderItemsByPreferredIds", () => { getId: getProjectOrderKey, }); - expect(ordered.map((project) => project.cwd)).toEqual([ + expect(ordered.map((project) => project.workspaceRoot)).toEqual([ "/work/gamma", "/work/alpha", "/work/beta", @@ -481,11 +479,14 @@ describe("resolveThreadStatusPill", () => { latestTurn: null, lastVisitedAt: undefined, session: { - provider: ProviderDriverKind.make("codex"), + threadId: ThreadId.make("thread-1"), status: "running" as const, - createdAt: "2026-03-09T10:00:00.000Z", + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, updatedAt: "2026-03-09T10:00:00.000Z", - orchestrationStatus: "running" as const, }, }; @@ -530,7 +531,7 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), @@ -546,7 +547,7 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), @@ -564,7 +565,7 @@ describe("resolveThreadStatusPill", () => { session: { ...baseThread.session, status: "ready", - orchestrationStatus: "ready", + activeTurnId: null, }, }, }), @@ -702,8 +703,9 @@ function makeProject(overrides: Partial = {}): Project { return { id: ProjectId.make("project-1"), environmentId: localEnvironmentId, - name: "Project", - cwd: "/tmp/project", + title: "Project", + workspaceRoot: "/tmp/project", + repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", @@ -720,7 +722,6 @@ function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: localEnvironmentId, - codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { @@ -733,14 +734,14 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], proposedPlans: [], - error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, - turnDiffSummaries: [], + checkpoints: [], activities: [], ...overrides, }; @@ -815,8 +816,8 @@ describe("getFallbackThreadIdAfterDelete", () => { describe("sortProjectsForSidebar", () => { it("sorts projects by the most recent user message across their threads", () => { const projects = [ - makeProject({ id: ProjectId.make("project-1"), name: "Older project" }), - makeProject({ id: ProjectId.make("project-2"), name: "Newer project" }), + makeProject({ id: ProjectId.make("project-1"), title: "Older project" }), + makeProject({ id: ProjectId.make("project-2"), title: "Newer project" }), ]; const threads = [ makeThread({ @@ -827,9 +828,10 @@ describe("sortProjectsForSidebar", () => { id: "message-1" as never, role: "user", text: "older project user message", + turnId: null, createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", }, ], }), @@ -842,9 +844,10 @@ describe("sortProjectsForSidebar", () => { id: "message-2" as never, role: "user", text: "newer project user message", + turnId: null, createdAt: "2026-03-09T10:05:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", streaming: false, - completedAt: "2026-03-09T10:05:00.000Z", }, ], }), @@ -863,12 +866,12 @@ describe("sortProjectsForSidebar", () => { [ makeProject({ id: ProjectId.make("project-1"), - name: "Older project", + title: "Older project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.make("project-2"), - name: "Newer project", + title: "Newer project", updatedAt: "2026-03-09T10:05:00.000Z", }), ], @@ -887,15 +890,15 @@ describe("sortProjectsForSidebar", () => { [ makeProject({ id: ProjectId.make("project-2"), - name: "Beta", - createdAt: undefined, - updatedAt: undefined, + title: "Beta", + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, }), makeProject({ id: ProjectId.make("project-1"), - name: "Alpha", - createdAt: undefined, - updatedAt: undefined, + title: "Alpha", + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, }), ], [], @@ -910,8 +913,8 @@ describe("sortProjectsForSidebar", () => { it("preserves manual project ordering", () => { const projects = [ - makeProject({ id: ProjectId.make("project-2"), name: "Second" }), - makeProject({ id: ProjectId.make("project-1"), name: "First" }), + makeProject({ id: ProjectId.make("project-2"), title: "Second" }), + makeProject({ id: ProjectId.make("project-1"), title: "First" }), ]; const sorted = sortProjectsForSidebar(projects, [], "manual"); @@ -927,12 +930,12 @@ describe("sortProjectsForSidebar", () => { [ makeProject({ id: ProjectId.make("project-1"), - name: "Visible project", + title: "Visible project", updatedAt: "2026-03-09T10:01:00.000Z", }), makeProject({ id: ProjectId.make("project-2"), - name: "Archived-only project", + title: "Archived-only project", updatedAt: "2026-03-09T10:00:00.000Z", }), ], diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b9dd27dfb03..6ace12f9b1b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -18,7 +18,7 @@ export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; type SidebarProject = { id: string; - name: string; + title: string; createdAt?: string | undefined; updatedAt?: string | undefined; }; @@ -358,7 +358,7 @@ export function resolveThreadStatusPill(input: { }; } - if (thread.session?.status === "connecting") { + if (thread.session?.status === "starting") { return { label: "Connecting", colorClass: "text-sky-600 dark:text-sky-300/80", @@ -536,6 +536,6 @@ export function sortProjectsForSidebar< const byTimestamp = rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1; if (byTimestamp !== 0) return byTimestamp; - return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); + return left.title.localeCompare(right.title) || left.id.localeCompare(right.id); }); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index dc5acaaadc7..15214dce5f8 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, @@ -18,6 +19,7 @@ import { ThreadStatusLabel, } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; @@ -41,6 +43,7 @@ import { type DesktopUpdateState, ProjectId, type ScopedThreadRef, + type ResolvedKeybindingsConfig, type SidebarProjectGroupingMode, type ThreadEnvMode, ThreadId, @@ -51,7 +54,7 @@ import { scopedThreadKey, scopeProjectRef, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import { Link, useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -60,21 +63,22 @@ import { type SidebarThreadPreviewCount, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; -import { usePrimaryEnvironmentId } from "../environments/primary"; import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform, newCommandId } from "../lib/utils"; +import { isMacPlatform } from "../lib/utils"; import { - selectProjectByRef, - selectProjectsAcrossEnvironments, - selectSidebarThreadsForProjectRefs, - selectSidebarThreadsAcrossEnvironments, - selectThreadByRef, - useStore, -} from "../store"; + readThreadShell, + useProject, + useProjects, + useThreadShells, + useThreadShellsForProjectRefs, +} from "../state/entities"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { useThreadDiscoveredPorts } from "../portDiscoveryState"; +import { openDiscoveredPort } from "./preview/openDiscoveredPort"; +import { usePreviewActions } from "../state/preview"; import { useUiStateStore } from "../uiStateStore"; import { resolveShortcutCommand, @@ -86,13 +90,16 @@ import { } from "../keybindings"; import { useModelPickerOpen } from "../modelPickerOpenState"; import { useShortcutModifierState } from "../shortcutModifierState"; -import { useVcsStatus } from "../lib/vcsStatusState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { retainThreadDetailSubscription } from "../environments/runtime/service"; import { useThreadActions } from "../hooks/useThreadActions"; +import { projectEnvironment } from "../state/projects"; +import { useEnvironmentQuery } from "../state/query"; +import { threadEnvironment, useEnvironmentThread } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -177,19 +184,14 @@ import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { CommandDialogTrigger } from "./ui/command"; -import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; -import { useServerKeybindings } from "../rpc/serverState"; +import { primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; import type { SidebarThreadSummary } from "../types"; import { buildPhysicalToLogicalProjectKeyMap, @@ -220,6 +222,11 @@ const PROJECT_GROUPING_MODE_LABELS: Record = const SIDEBAR_ICON_ACTION_BUTTON_CLASS = "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-[calc(--spacing(1)-1px)] text-muted-foreground/60 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; +function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + useEnvironmentThread(threadRef.environmentId, threadRef.threadId); + return null; +} + function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { return Math.min( MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -232,10 +239,12 @@ function formatProjectMemberActionLabel( groupedProjectCount: number, ): string { if (groupedProjectCount <= 1) { - return member.name; + return member.title; } - return member.environmentLabel ? `${member.environmentLabel} — ${member.cwd}` : member.cwd; + return member.environmentLabel + ? `${member.environmentLabel} — ${member.workspaceRoot}` + : member.workspaceRoot; } function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { @@ -250,7 +259,7 @@ function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): strin } function buildThreadJumpLabelMap(input: { - keybindings: ReturnType; + keybindings: ResolvedKeybindingsConfig; platform: string; terminalOpen: boolean; threadJumpCommandByKey: ReadonlyMap< @@ -349,35 +358,48 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const discoveredPorts = useThreadDiscoveredPorts({ + environmentId: thread.environmentId, + threadId: thread.id, + }); + const { open: openPreview } = usePreviewActions(); + const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (s) => s.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (s) => s.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; + const remoteEnvLabel = environment?.label ?? null; + const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. - const threadProjectCwd = useStore( + const threadProject = useProject( useMemo( - () => (state: import("../store").AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, + () => scopeProjectRef(thread.environmentId, thread.projectId), [thread.environmentId, thread.projectId], ), ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; - const gitStatus = useVcsStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); const isHighlighted = isActive || isSelected; + const handleOpenDiscoveredPort = useCallback( + (event: React.MouseEvent) => { + const port = discoveredPorts[0]; + if (!port) return; + event.preventDefault(); + event.stopPropagation(); + navigateToThread(threadRef); + void openDiscoveredPort({ threadRef, port, openPreview }); + }, + [discoveredPorts, navigateToThread, openPreview, threadRef], + ); const isThreadRunning = thread.session?.status === "running" && thread.session.activeTurnId != null; const threadStatus = resolveThreadStatusPill({ @@ -609,6 +631,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )}
+ {discoveredPorts.length > 0 && ( + + + } + > + + + + Open localhost:{discoveredPorts[0]?.port} + {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} + + + )} {terminalStatus && ( settings.defaultThreadEnvMode, ); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const deleteProject = useAtomSet(projectEnvironment.delete, { mode: "promise" }); + const updateProject = useAtomSet(projectEnvironment.update, { mode: "promise" }); + const updateThreadMetadata = useAtomSet(threadEnvironment.updateMetadata, { + mode: "promise", + }); const { updateSettings } = useUpdateSettings(); const sidebarThreadPreviewCount = useSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -1034,15 +1081,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); }); }, []); - const sidebarThreads = useStore( - useShallow( - useMemo( - () => (state: import("../store").AppState) => - selectSidebarThreadsForProjectRefs(state, project.memberProjectRefs), - [project.memberProjectRefs], - ), - ), - ); + const sidebarThreads = useThreadShellsForProjectRefs(project.memberProjectRefs); const sidebarThreadByKey = useMemo( () => new Map( @@ -1146,7 +1185,6 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec visibleProjectThreads, }; }, [projectThreads, threadLastVisitedAts, threadSortOrder]); - const pinnedCollapsedThread = useMemo(() => { const activeThreadKey = activeRouteThreadKey ?? undefined; if (!activeThreadKey || projectExpanded) { @@ -1288,7 +1326,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const openProjectRenameDialog = useCallback((member: SidebarProjectGroupMember) => { setProjectRenameTarget(member); - setProjectRenameTitle(member.name); + setProjectRenameTitle(member.title); }, []); const openProjectGroupingDialog = useCallback( @@ -1312,19 +1350,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } draftStore.clearProjectDraftThreadId(memberProjectRef); - const projectApi = readEnvironmentApi(member.environmentId); - if (!projectApi) { - throw new Error("Project API unavailable."); - } - - await projectApi.orchestration.dispatchCommand({ - type: "project.delete", - commandId: newCommandId(), - projectId: member.id, - ...(options.force === true ? { force: true } : {}), + await deleteProject({ + environmentId: member.environmentId, + input: { + projectId: member.id, + ...(options.force === true ? { force: true } : {}), + }, }); }, - [], + [deleteProject], ); const handleRemoveProject = useCallback( @@ -1352,17 +1386,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec window.setTimeout(resolve, 180); }); - const latestProjectThreads = selectSidebarThreadsForProjectRefs( - useStore.getState(), - [memberProjectRef], + const latestProjectThreads = Array.from( + sidebarThreadByKeyRef.current.values(), + ).filter( + (thread) => + thread.environmentId === memberProjectRef.environmentId && + thread.projectId === memberProjectRef.projectId, ); const confirmed = await api.dialogs.confirm( latestProjectThreads.length > 0 ? [ - `Remove project "${member.name}" and delete its ${latestProjectThreads.length} thread${ + `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ latestProjectThreads.length === 1 ? "" : "s" }?`, - `Path: ${member.cwd}`, + `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), @@ -1371,8 +1408,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec "This action cannot be undone.", ].join("\n") : [ - `Remove project "${member.name}"?`, - `Path: ${member.cwd}`, + `Remove project "${member.title}"?`, + `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), @@ -1395,7 +1432,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${member.name}"`, + title: `Failed to remove "${member.title}"`, description: message, }), ); @@ -1408,8 +1445,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } const message = [ - `Remove project "${member.name}"?`, - `Path: ${member.cwd}`, + `Remove project "${member.title}"?`, + `Path: ${member.workspaceRoot}`, ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), "This removes only this project entry.", ].join("\n"); @@ -1430,7 +1467,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec toastManager.add( stackedThreadToast({ type: "error", - title: `Failed to remove "${member.name}"`, + title: `Failed to remove "${member.title}"`, description: message, }), ); @@ -1466,7 +1503,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec openProjectGroupingDialog(member); return; case "copy-path": - copyPathToClipboard(member.cwd, { path: member.cwd }); + copyPathToClipboard(member.workspaceRoot, { path: member.workspaceRoot }); return; case "delete": return handleRemoveProject(member); @@ -1674,7 +1711,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const currentRouteTarget = resolveThreadRouteTarget(currentRouteParams); const currentActiveThread = currentRouteTarget?.kind === "server" - ? (selectThreadByRef(useStore.getState(), currentRouteTarget.threadRef) ?? null) + ? readThreadShell(currentRouteTarget.threadRef) : null; const draftStore = useComposerDraftStore.getState(); const currentActiveDraftThread = @@ -1806,17 +1843,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec finishRename(); return; } - const api = readEnvironmentApi(threadRef.environmentId); - if (!api) { - finishRename(); - return; - } try { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadRef.threadId, - title: trimmed, + await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + title: trimmed, + }, }); } catch (error) { toastManager.add( @@ -1829,7 +1862,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec } finishRename(); }, - [], + [updateThreadMetadata], ); const closeProjectRenameDialog = useCallback(() => { @@ -1851,29 +1884,18 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - if (trimmed === projectRenameTarget.name) { + if (trimmed === projectRenameTarget.title) { closeProjectRenameDialog(); return; } - const api = readEnvironmentApi(projectRenameTarget.environmentId); - if (!api) { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to rename project", - description: "Project API unavailable.", - }), - ); - return; - } - try { - await api.orchestration.dispatchCommand({ - type: "project.meta.update", - commandId: newCommandId(), - projectId: projectRenameTarget.id, - title: trimmed, + await updateProject({ + environmentId: projectRenameTarget.environmentId, + input: { + projectId: projectRenameTarget.id, + title: trimmed, + }, }); closeProjectRenameDialog(); } catch (error) { @@ -1885,7 +1907,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }), ); } - }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle]); + }, [closeProjectRenameDialog, projectRenameTarget, projectRenameTitle, updateProject]); const closeProjectGroupingDialog = useCallback(() => { setProjectGroupingTarget(null); @@ -1928,7 +1950,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const threadProject = memberProjectByScopedKey.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); - const threadWorkspacePath = thread.worktreePath ?? threadProject?.cwd ?? project.cwd ?? null; + const threadWorkspacePath = + thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, @@ -1990,7 +2013,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec deleteThread, markThreadUnread, memberProjectByScopedKey, - project.cwd, + project.workspaceRoot, ], ); @@ -2038,7 +2061,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }`} /> )} - + {project.displayName} @@ -2106,7 +2129,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec showEmptyThreadState={showEmptyThreadState} shouldShowThreadPanel={shouldShowThreadPanel} isThreadListExpanded={isThreadListExpanded} - projectCwd={project.cwd} + projectCwd={project.workspaceRoot} activeRouteThreadKey={activeRouteThreadKey} threadJumpLabelByKey={threadJumpLabelByKey} appSettingsConfirmThreadArchive={appSettingsConfirmThreadArchive} @@ -2145,7 +2168,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec Rename project {projectRenameTarget - ? `Update the title for ${projectRenameTarget.cwd}.` + ? `Update the title for ${projectRenameTarget.workspaceRoot}.` : "Update the project title."} @@ -2192,7 +2215,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec Project grouping {projectGroupingTarget - ? `Choose how ${projectGroupingTarget.cwd} should be grouped in the sidebar.` + ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` : "Choose how this project should be grouped in the sidebar."} @@ -2811,8 +2834,8 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }); export default function Sidebar() { - const projects = useStore(useShallow(selectProjectsAcrossEnvironments)); - const sidebarThreads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments)); + const projects = useProjects(); + const sidebarThreads = useThreadShells(); const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const projectOrder = useUiStateStore((store) => store.projectOrder); const reorderProjects = useUiStateStore((store) => store.reorderProjects); @@ -2833,7 +2856,7 @@ export default function Sidebar() { select: (params) => resolveThreadRouteRef(params), }); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const keybindings = useServerKeybindings(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const openAddProjectCommandPalette = useCommandPaletteStore((store) => store.openAddProject); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet @@ -2848,9 +2871,15 @@ export default function Sidebar() { const platform = navigator.platform; const shortcutModifiers = useShortcutModifierState(); const modelPickerOpen = useModelPickerOpen(); + const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((s) => s.byId); - const savedEnvironmentRuntimeById = useSavedEnvironmentRuntimeStore((s) => s.byId); + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -2884,19 +2913,9 @@ export default function Sidebar() { projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => { - const rt = savedEnvironmentRuntimeById[environmentId]; - const saved = savedEnvironmentRegistry[environmentId]; - return rt?.descriptor?.label ?? saved?.label ?? null; - }, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }); - }, [ - orderedProjects, - projectGroupingSettings, - primaryEnvironmentId, - savedEnvironmentRegistry, - savedEnvironmentRuntimeById, - ]); + }, [environmentLabelById, orderedProjects, projectGroupingSettings, primaryEnvironmentId]); const sidebarProjectByKey = useMemo( () => new Map(sidebarProjects.map((project) => [project.projectKey, project] as const)), @@ -3202,18 +3221,6 @@ export default function Sidebar() { [prewarmedSidebarThreadKeys], ); - useEffect(() => { - const releases = prewarmedSidebarThreadRefs.map((ref) => - retainThreadDetailSubscription(ref.environmentId, ref.threadId), - ); - - return () => { - for (const release of releases) { - release(); - } - }; - }, [prewarmedSidebarThreadRefs]); - useEffect(() => { updateThreadJumpHintsVisibility(shouldShowThreadJumpHintsNow); }, [shouldShowThreadJumpHintsNow, updateThreadJumpHintsVisibility]); @@ -3438,6 +3445,9 @@ export default function Sidebar() { return ( <> + {prewarmedSidebarThreadRefs.map((threadRef) => ( + + ))} {isOnSettings ? ( diff --git a/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx new file mode 100644 index 00000000000..07711ca84b7 --- /dev/null +++ b/apps/web/src/components/SlowRpcRequestToastCoordinator.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from "react"; + +import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; +import { toastManager } from "./ui/toast"; + +function describeSlowRequests(requests: ReadonlyArray): string { + const count = requests.length; + const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); + + return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; +} + +function SlowRequestDetails({ requests }: { requests: ReadonlyArray }) { + return ( +
    + {requests.map((request) => ( +
  • +
    {request.tag}
    +
    + Started {new Date(request.startedAt).toLocaleTimeString()} +
    +
  • + ))} +
+ ); +} + +export function SlowRpcRequestToastCoordinator() { + const slowRequests = useSlowRpcAckRequests(); + const toastIdRef = useRef | null>(null); + + useEffect(() => { + if (slowRequests.length === 0) { + if (toastIdRef.current !== null) { + toastManager.close(toastIdRef.current); + toastIdRef.current = null; + } + return; + } + + const nextToast = { + data: { + expandableContent: , + expandableDescriptionTrigger: true, + expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, + }, + description: describeSlowRequests(slowRequests), + timeout: 0, + title: "Some requests are slow", + type: "warning" as const, + }; + + if (toastIdRef.current === null) { + toastIdRef.current = toastManager.add(nextToast); + } else { + toastManager.update(toastIdRef.current, nextToast); + } + }, [slowRequests]); + + useEffect( + () => () => { + if (toastIdRef.current !== null) { + toastManager.close(toastIdRef.current); + } + }, + [], + ); + + return null; +} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ed2df1c79a0..8eac1fa412a 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,15 +1,16 @@ -import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { + scopeProjectRef, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { usePrimaryEnvironmentId } from "../environments/primary"; -import { - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, -} from "../environments/runtime"; -import { useVcsStatus } from "../lib/vcsStatusState"; -import { type AppState, selectProjectByRef, useStore } from "../store"; -import { useThreadRunningTerminalIds } from "../terminalSessionState"; +import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; +import { useProject } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; @@ -154,19 +155,22 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const lastVisitedAt = useUiStateStore( (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], ); - const threadProjectCwd = useStore( + const threadProject = useProject( useMemo( - () => (state: AppState) => - selectProjectByRef(state, scopeProjectRef(thread.environmentId, thread.projectId))?.cwd ?? - null, + () => scopeProjectRef(thread.environmentId, thread.projectId), [thread.environmentId, thread.projectId], ), ); + const threadProjectCwd = threadProject?.workspaceRoot ?? null; const gitCwd = thread.worktreePath ?? threadProjectCwd; - const gitStatus = useVcsStatus({ - environmentId: thread.environmentId, - cwd: thread.branch != null ? gitCwd : null, - }); + const gitStatus = useEnvironmentQuery( + thread.branch != null && gitCwd !== null + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { cwd: gitCwd }, + }) + : null, + ); const pr = resolveThreadPr(thread.branch, gitStatus.data); const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ @@ -212,18 +216,12 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma environmentId: thread.environmentId, threadId: thread.id, }); + const environment = useEnvironment(thread.environmentId); const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteThread = primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = useSavedEnvironmentRuntimeStore( - (state) => state.byId[thread.environmentId]?.descriptor?.label ?? null, - ); - const remoteEnvSavedLabel = useSavedEnvironmentRegistryStore( - (state) => state.byId[thread.environmentId]?.label ?? null, - ); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? remoteEnvSavedLabel ?? "Remote") - : null; + const remoteEnvLabel = environment?.label ?? null; + const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); if (!terminalStatus && !isRemoteThread) { diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx index 56482c44e8e..b4eee3a932a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.tsx @@ -1,7 +1,7 @@ import "../index.css"; -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { ThreadId } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { render } from "vitest-browser-react"; @@ -10,26 +10,34 @@ const { terminalDisposeSpy, fitAddonFitSpy, fitAddonLoadSpy, - environmentApiById, - readEnvironmentApiMock, + terminalControllerByEnvironmentId, + useTerminalControllerMock, readLocalApiMock, } = vi.hoisted(() => ({ terminalConstructorSpy: vi.fn(), terminalDisposeSpy: vi.fn(), fitAddonFitSpy: vi.fn(), fitAddonLoadSpy: vi.fn(), - environmentApiById: new Map< + terminalControllerByEnvironmentId: new Map< string, { - terminal: { - open: ReturnType; - attach: ReturnType; - write: ReturnType; - resize: ReturnType; + session: { + summary: null; + buffer: string; + status: "running"; + error: null; + hasRunningSubprocess: false; + updatedAt: null; + version: number; }; + write: ReturnType; + resize: ReturnType; + clear: ReturnType; + restart: ReturnType; + close: ReturnType; } >(), - readEnvironmentApiMock: vi.fn((environmentId: string) => environmentApiById.get(environmentId)), + useTerminalControllerMock: vi.fn(), readLocalApiMock: vi.fn< () => | { @@ -118,10 +126,24 @@ vi.mock("@xterm/xterm", () => ({ }, })); -vi.mock("~/environmentApi", () => ({ - readEnvironmentApi: readEnvironmentApiMock, +vi.mock("../state/terminalSessions", () => ({ + useTerminalController: (input: { environmentId: string }) => { + useTerminalControllerMock(input); + const controller = terminalControllerByEnvironmentId.get(input.environmentId); + if (controller === undefined) { + throw new Error(`Missing test terminal controller for ${input.environmentId}`); + } + return controller; + }, })); +vi.mock("../state/server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { + primaryServerAvailableEditorsAtom: Atom.make([]), + }; +}); + vi.mock("~/localApi", () => ({ ensureLocalApi: vi.fn(() => { throw new Error("ensureLocalApi not implemented in browser test"); @@ -133,37 +155,22 @@ import { TerminalViewport } from "./ThreadTerminalDrawer"; const THREAD_ID = ThreadId.make("thread-terminal-browser"); -function createEnvironmentApi() { - const snapshot = { - threadId: THREAD_ID, - terminalId: "term-1", - cwd: "/repo/project", - worktreePath: null, - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "Terminal 1", - updatedAt: "2026-04-07T00:00:00.000Z", - }; - +function createTerminalController() { return { - terminal: { - open: vi.fn(async () => snapshot), - attach: vi.fn( - ( - _input: unknown, - listener: (event: TerminalAttachStreamEvent) => void, - _options?: unknown, - ) => { - listener({ type: "snapshot", snapshot }); - return vi.fn(); - }, - ), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), + session: { + summary: null, + buffer: "", + status: "running" as const, + error: null, + hasRunningSubprocess: false as const, + updatedAt: null, + version: 1, }, + write: vi.fn(async () => undefined), + resize: vi.fn(async () => undefined), + clear: vi.fn(async () => undefined), + restart: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), }; } @@ -239,8 +246,8 @@ async function mountTerminalViewport(props: { describe("TerminalViewport", () => { afterEach(() => { - environmentApiById.clear(); - readEnvironmentApiMock.mockClear(); + terminalControllerByEnvironmentId.clear(); + useTerminalControllerMock.mockClear(); readLocalApiMock.mockClear(); terminalConstructorSpy.mockClear(); terminalDisposeSpy.mockClear(); @@ -248,8 +255,8 @@ describe("TerminalViewport", () => { fitAddonLoadSpy.mockClear(); }); - it("does not create a terminal when APIs are unavailable", async () => { - readEnvironmentApiMock.mockReturnValueOnce(undefined); + it("renders the terminal through the shared terminal controller without the desktop API", async () => { + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); readLocalApiMock.mockReturnValueOnce(undefined); const mounted = await mountTerminalViewport({ @@ -258,25 +265,9 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(terminalConstructorSpy).not.toHaveBeenCalled(); - }); - } finally { - await mounted.cleanup(); - } - }); - - it("renders and attaches the terminal without the desktop local API", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); - readLocalApiMock.mockReturnValueOnce(undefined); - - const mounted = await mountTerminalViewport({ - threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), - }); - - try { - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(useTerminalControllerMock).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "environment-a" }), + ); }); expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); } finally { @@ -285,8 +276,7 @@ describe("TerminalViewport", () => { }); it("keeps the terminal mounted when xterm fit runs before dimensions are ready", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); fitAddonFitSpy.mockImplementationOnce(() => { throw new TypeError("Cannot read properties of undefined (reading 'dimensions')"); }); @@ -297,9 +287,8 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); - expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); expect(fitAddonFitSpy).toHaveBeenCalled(); } finally { await mounted.cleanup(); @@ -307,10 +296,8 @@ describe("TerminalViewport", () => { }); it("reattaches the terminal when the scoped thread reference changes", async () => { - const environmentA = createEnvironmentApi(); - const environmentB = createEnvironmentApi(); - environmentApiById.set("environment-a", environmentA); - environmentApiById.set("environment-b", environmentB); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); + terminalControllerByEnvironmentId.set("environment-b", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), @@ -318,7 +305,7 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environmentA.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); await mounted.rerender({ @@ -326,17 +313,19 @@ describe("TerminalViewport", () => { }); await vi.waitFor(() => { - expect(environmentB.terminal.attach).toHaveBeenCalledTimes(1); + expect(useTerminalControllerMock).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "environment-b" }), + ); }); expect(terminalDisposeSpy).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(2); } finally { await mounted.cleanup(); } }); it("does not reattach the terminal when the scoped thread reference values stay the same", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), @@ -344,16 +333,14 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); await mounted.rerender({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), }); - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); expect(terminalDisposeSpy).not.toHaveBeenCalled(); } finally { await mounted.cleanup(); @@ -361,8 +348,7 @@ describe("TerminalViewport", () => { }); it("does not reattach when runtime env contents are unchanged but object identity changes", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), @@ -371,7 +357,7 @@ describe("TerminalViewport", () => { try { await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); }); await mounted.rerender({ @@ -379,9 +365,7 @@ describe("TerminalViewport", () => { runtimeEnv: { T3: "1", PATH: "/usr/bin" }, }); - await vi.waitFor(() => { - expect(environment.terminal.attach).toHaveBeenCalledTimes(1); - }); + expect(terminalConstructorSpy).toHaveBeenCalledTimes(1); expect(terminalDisposeSpy).not.toHaveBeenCalled(); } finally { await mounted.cleanup(); @@ -389,8 +373,7 @@ describe("TerminalViewport", () => { }); it("uses the drawer surface colors for the terminal theme", async () => { - const environment = createEnvironmentApi(); - environmentApiById.set("environment-a", environment); + terminalControllerByEnvironmentId.set("environment-a", createTerminalController()); const mounted = await mountTerminalViewport({ threadRef: scopeThreadRef("environment-a" as never, THREAD_ID), diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index b740a9ed7cf..f43b57c6316 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -3,8 +3,6 @@ import { Plus, SquareSplitHorizontal, TerminalSquare, Trash2, XIcon } from "luci import { type ResolvedKeybindingsConfig, type ScopedThreadRef, - type TerminalAttachStreamEvent, - type TerminalSessionSnapshot, type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -21,7 +19,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { type TerminalContextSelection } from "~/lib/terminalContext"; -import { openInPreferredEditor } from "../editorPreferences"; +import { useOpenInPreferredEditor } from "../editorPreferences"; import { collectWrappedTerminalLinkLine, extractTerminalLinks, @@ -45,9 +43,12 @@ import { MAX_TERMINALS_PER_GROUP, type ThreadTerminalGroup, } from "../types"; -import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; -import { attachTerminalSession } from "../terminalSessionState"; +import { useTerminalController } from "../state/terminalSessions"; +import { useEnvironmentQuery } from "../state/query"; +import { serverEnvironment } from "../state/server"; +import { usePreviewActions } from "../state/preview"; +import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -68,10 +69,10 @@ function writeSystemMessage(terminal: Terminal, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalSnapshot(terminal: Terminal, snapshot: TerminalSessionSnapshot): void { +function writeTerminalBuffer(terminal: Terminal, buffer: string): void { terminal.write("\u001bc"); - if (snapshot.history.length > 0) { - terminal.write(snapshot.history); + if (buffer.length > 0) { + terminal.write(buffer); } } @@ -294,6 +295,13 @@ export function TerminalViewport({ const terminalRef = useRef(null); const fitAddonRef = useRef(null); const environmentId = threadRef.environmentId; + const serverConfig = useEnvironmentQuery(serverEnvironment.config({ environmentId, input: {} })); + const openInPreferredEditor = useOpenInPreferredEditor( + environmentId, + serverConfig.data?.availableEditors ?? [], + ); + const openTerminalPath = useEffectEvent((target: string) => openInPreferredEditor(target)); + const { open: openPreview } = usePreviewActions(); const hasHandledExitRef = useRef(false); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); @@ -309,6 +317,20 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + const terminalController = useTerminalController({ + environmentId, + terminal: { + threadId, + terminalId, + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }, + }); + const writeTerminal = useEffectEvent(terminalController.write); + const resizeTerminal = useEffectEvent(terminalController.resize); + const readTerminalSession = useEffectEvent(() => terminalController.session); + const previousSessionRef = useRef(terminalController.session); useEffect(() => { keybindingsRef.current = keybindings; @@ -318,10 +340,7 @@ export function TerminalViewport({ const mount = containerRef.current; if (!mount) return; - let disposed = false; - const api = readEnvironmentApi(environmentId); const localApi = readLocalApi(); - if (!api) return; const fitAddon = new FitAddon(); const terminal = new Terminal({ @@ -339,6 +358,13 @@ export function TerminalViewport({ terminalRef.current = terminal; fitAddonRef.current = fitAddon; + previousSessionRef.current = { + ...readTerminalSession(), + buffer: "", + status: "closed", + error: null, + version: 0, + }; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -423,7 +449,7 @@ export function TerminalViewport({ const activeTerminal = terminalRef.current; if (!activeTerminal) return; try { - await api.terminal.write({ threadId, terminalId, data }); + await writeTerminal(data); } catch (error) { writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); } @@ -503,23 +529,36 @@ export function TerminalViewport({ const latestTerminal = terminalRef.current; if (!latestTerminal) return; - if (!localApi) { - writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); - return; - } if (match.kind === "url") { - void localApi.shell.openExternal(match.text).catch((error: unknown) => { + if (!localApi) { writeSystemMessage( latestTerminal, - error instanceof Error ? error.message : "Unable to open link", + "Opening links is unavailable in this browser.", ); + return; + } + const fallbackToBrowser = () => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + void openTerminalLinkInPreview({ + url: match.text, + position: { x: event.clientX, y: event.clientY }, + threadRef, + openPreview, + localApi, + fallbackToBrowser, }); return; } const target = resolvePathLinkTarget(match.text, cwd); - void openInPreferredEditor(localApi, target).catch((error) => { + void openTerminalPath(target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", @@ -532,14 +571,9 @@ export function TerminalViewport({ }); const inputDisposable = terminal.onData((data) => { - void api.terminal - .write({ threadId, terminalId, data }) - .catch((err) => - writeSystemMessage( - terminal, - err instanceof Error ? err.message : "Terminal write failed", - ), - ); + void writeTerminal(data).catch((err) => + writeSystemMessage(terminal, err instanceof Error ? err.message : "Terminal write failed"), + ); }); const selectionDisposable = terminal.onSelectionChange(() => { @@ -585,107 +619,6 @@ export function TerminalViewport({ attributeFilter: ["class", "style"], }); - const applyAttachEvent = (event: TerminalAttachStreamEvent) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) { - return; - } - - if (event.type === "activity") { - return; - } - - if (event.type === "snapshot") { - hasHandledExitRef.current = false; - clearSelectionAction(); - writeTerminalSnapshot(activeTerminal, event.snapshot); - return; - } - - if (event.type === "output") { - activeTerminal.write(event.data); - clearSelectionAction(); - return; - } - - if (event.type === "restarted") { - hasHandledExitRef.current = false; - clearSelectionAction(); - writeTerminalSnapshot(activeTerminal, event.snapshot); - return; - } - - if (event.type === "cleared") { - clearSelectionAction(); - activeTerminal.clear(); - activeTerminal.write("\u001bc"); - return; - } - - if (event.type === "error") { - writeSystemMessage(activeTerminal, event.message); - return; - } - - if (event.type === "closed") { - writeSystemMessage(activeTerminal, "Terminal closed"); - } else { - const details = [ - typeof event.exitCode === "number" ? `code ${event.exitCode}` : null, - typeof event.exitSignal === "number" ? `signal ${event.exitSignal}` : null, - ] - .filter((value): value is string => value !== null) - .join(", "); - writeSystemMessage( - activeTerminal, - details.length > 0 ? `Process exited (${details})` : "Process exited", - ); - } - - if (hasHandledExitRef.current) { - return; - } - hasHandledExitRef.current = true; - window.setTimeout(() => { - if (!hasHandledExitRef.current) { - return; - } - handleSessionExited(); - }, 0); - }; - let unsubscribeAttach: (() => void) | null = null; - const attachTerminal = () => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - fitTerminalSafely(activeFitAddon); - unsubscribeAttach = attachTerminalSession({ - environmentId, - client: api, - terminal: { - threadId, - terminalId, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - cols: activeTerminal.cols, - rows: activeTerminal.rows, - ...(runtimeEnv ? { env: runtimeEnv } : {}), - }, - onEvent: (event) => { - if (disposed) return; - applyAttachEvent(event); - }, - onSnapshot: () => { - if (disposed) return; - if (autoFocus) { - window.requestAnimationFrame(() => { - activeTerminal.focus(); - }); - } - }, - }); - }; - const fitTimer = window.setTimeout(() => { const activeTerminal = terminalRef.current; const activeFitAddon = fitAddonRef.current; @@ -696,21 +629,10 @@ export function TerminalViewport({ if (wasAtBottom) { activeTerminal.scrollToBottom(); } - void api.terminal - .resize({ - threadId, - terminalId, - cols: activeTerminal.cols, - rows: activeTerminal.rows, - }) - .catch(() => undefined); + void resizeTerminal(activeTerminal.cols, activeTerminal.rows).catch(() => undefined); }, 30); - attachTerminal(); return () => { - disposed = true; - unsubscribeAttach?.(); - unsubscribeAttach = null; window.clearTimeout(fitTimer); inputDisposable.dispose(); selectionDisposable.dispose(); @@ -730,6 +652,66 @@ export function TerminalViewport({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); + useEffect(() => { + const terminal = terminalRef.current; + if (!terminal) { + previousSessionRef.current = terminalController.session; + return; + } + + const previous = previousSessionRef.current; + const current = terminalController.session; + if (current.version === previous.version) { + return; + } + + if ( + current.buffer.length >= previous.buffer.length && + current.buffer.startsWith(previous.buffer) + ) { + terminal.write(current.buffer.slice(previous.buffer.length)); + } else { + writeTerminalBuffer(terminal, current.buffer); + } + terminal.clearSelection(); + + if (current.error !== null && current.error !== previous.error) { + writeSystemMessage(terminal, current.error); + } + + if (current.status === "running") { + hasHandledExitRef.current = false; + } else if ( + (current.status === "closed" || current.status === "exited") && + current.status !== previous.status && + !hasHandledExitRef.current + ) { + hasHandledExitRef.current = true; + writeSystemMessage( + terminal, + current.status === "closed" ? "Terminal closed" : "Process exited", + ); + window.setTimeout(() => { + if (hasHandledExitRef.current) { + handleSessionExited(); + } + }, 0); + } + + if (previous.version === 0 && autoFocus) { + window.requestAnimationFrame(() => { + terminal.focus(); + }); + } + previousSessionRef.current = current; + }, [ + autoFocus, + terminalController.session.buffer, + terminalController.session.error, + terminalController.session.status, + terminalController.session.version, + ]); + useEffect(() => { if (!autoFocus) return; const terminal = terminalRef.current; @@ -743,24 +725,16 @@ export function TerminalViewport({ }, [autoFocus, focusRequestId]); useEffect(() => { - const api = readEnvironmentApi(environmentId); const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; - if (!api || !terminal || !fitAddon) return; + if (!terminal || !fitAddon) return; const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; const frame = window.requestAnimationFrame(() => { fitTerminalSafely(fitAddon); if (wasAtBottom) { terminal.scrollToBottom(); } - void api.terminal - .resize({ - threadId, - terminalId, - cols: terminal.cols, - rows: terminal.rows, - }) - .catch(() => undefined); + void resizeTerminal(terminal.cols, terminal.rows).catch(() => undefined); }); return () => { window.cancelAnimationFrame(frame); diff --git a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts b/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts deleted file mode 100644 index a9673a96ee9..00000000000 --- a/apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import type { WsConnectionStatus } from "../rpc/wsConnectionState"; -import { shouldAutoReconnect, shouldRestartStalledReconnect } from "./WebSocketConnectionSurface"; - -function makeStatus(overrides: Partial = {}): WsConnectionStatus { - return { - attemptCount: 0, - closeCode: null, - closeReason: null, - connectionLabel: null, - connectedAt: null, - disconnectedAt: null, - hasConnected: false, - lastError: null, - lastErrorAt: null, - nextRetryAt: null, - online: true, - phase: "idle", - reconnectAttemptCount: 0, - reconnectMaxAttempts: 8, - reconnectPhase: "idle", - socketUrl: null, - ...overrides, - }; -} - -describe("WebSocketConnectionSurface.logic", () => { - it("forces reconnect on online when the app was offline", () => { - expect( - shouldAutoReconnect( - makeStatus({ - disconnectedAt: "2026-04-03T20:00:00.000Z", - online: false, - phase: "disconnected", - }), - "online", - ), - ).toBe(true); - }); - - it("forces reconnect on focus only for previously connected disconnected states", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(true); - - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: false, - online: true, - phase: "disconnected", - reconnectAttemptCount: 1, - reconnectPhase: "waiting", - }), - "focus", - ), - ).toBe(false); - }); - - it("forces reconnect on focus for exhausted reconnect loops", () => { - expect( - shouldAutoReconnect( - makeStatus({ - hasConnected: true, - online: true, - phase: "disconnected", - reconnectAttemptCount: 8, - reconnectPhase: "exhausted", - }), - "focus", - ), - ).toBe(true); - }); - - it("restarts a stalled reconnect window after the scheduled retry time passes", () => { - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "waiting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(true); - - expect( - shouldRestartStalledReconnect( - makeStatus({ - hasConnected: true, - nextRetryAt: "2026-04-03T20:00:01.000Z", - online: true, - phase: "disconnected", - reconnectAttemptCount: 3, - reconnectPhase: "attempting", - }), - "2026-04-03T20:00:01.000Z", - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/WebSocketConnectionSurface.tsx b/apps/web/src/components/WebSocketConnectionSurface.tsx deleted file mode 100644 index b54bd865c8b..00000000000 --- a/apps/web/src/components/WebSocketConnectionSurface.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; - -import { type SlowRpcAckRequest, useSlowRpcAckRequests } from "../rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - setBrowserOnlineStatus, - type WsConnectionStatus, - type WsConnectionUiState, - useWsConnectionStatus, - WS_RECONNECT_MAX_ATTEMPTS, -} from "../rpc/wsConnectionState"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { getPrimaryEnvironmentConnection } from "../environments/runtime"; - -const FORCED_WS_RECONNECT_DEBOUNCE_MS = 5_000; -type WsAutoReconnectTrigger = "focus" | "online"; - -const connectionTimeFormatter = new Intl.DateTimeFormat(undefined, { - day: "numeric", - hour: "numeric", - minute: "2-digit", - month: "short", - second: "2-digit", -}); - -function formatConnectionMoment(isoDate: string | null): string | null { - if (!isoDate) { - return null; - } - - return connectionTimeFormatter.format(new Date(isoDate)); -} - -function formatRetryCountdown(nextRetryAt: string, nowMs: number): string { - const remainingMs = Math.max(0, new Date(nextRetryAt).getTime() - nowMs); - return `${Math.max(1, Math.ceil(remainingMs / 1000))}s`; -} - -function describeOfflineToast(): string { - return "WebSocket disconnected. Waiting for network."; -} - -function formatReconnectAttemptLabel(status: WsConnectionStatus): string { - const reconnectAttempt = Math.max( - 1, - Math.min(status.reconnectAttemptCount, WS_RECONNECT_MAX_ATTEMPTS), - ); - return `Attempt ${reconnectAttempt}/${status.reconnectMaxAttempts}`; -} - -function describeExhaustedToast(): string { - return "Retries exhausted trying to reconnect"; -} - -function getConnectionDisplayName(status: WsConnectionStatus): string { - return status.connectionLabel?.trim() || "T3 Server"; -} - -function buildReconnectTitle(status: WsConnectionStatus): string { - return `Disconnected from ${getConnectionDisplayName(status)}`; -} - -function buildRecoveredTitle(status: WsConnectionStatus): string { - return `Reconnected to ${getConnectionDisplayName(status)}`; -} - -function describeRecoveredToast( - previousDisconnectedAt: string | null, - connectedAt: string | null, -): string { - const reconnectedAtLabel = formatConnectionMoment(connectedAt); - const disconnectedAtLabel = formatConnectionMoment(previousDisconnectedAt); - - if (disconnectedAtLabel && reconnectedAtLabel) { - return `Disconnected at ${disconnectedAtLabel} and reconnected at ${reconnectedAtLabel}.`; - } - - if (reconnectedAtLabel) { - return `Connection restored at ${reconnectedAtLabel}.`; - } - - return "Connection restored."; -} - -function describeSlowRpcAckToast(requests: ReadonlyArray): string { - const count = requests.length; - const thresholdSeconds = Math.round((requests[0]?.thresholdMs ?? 0) / 1000); - - return `${count} request${count === 1 ? "" : "s"} waiting longer than ${thresholdSeconds}s.`; -} - -function SlowRpcAckRequestDetails({ requests }: { requests: ReadonlyArray }) { - return ( -
    - {requests.map((req) => ( -
  • -
    {req.tag}
    -
    - {req.requestId} -
    -
    - Started {formatConnectionMoment(req.startedAt) ?? req.startedAt} -
    -
  • - ))} -
- ); -} - -export function shouldAutoReconnect( - status: WsConnectionStatus, - trigger: WsAutoReconnectTrigger, -): boolean { - const uiState = getWsConnectionUiState(status); - - if (trigger === "online") { - return ( - uiState === "offline" || - uiState === "reconnecting" || - uiState === "error" || - status.reconnectPhase === "exhausted" - ); - } - - return ( - status.online && - status.hasConnected && - (uiState === "reconnecting" || status.reconnectPhase === "exhausted") - ); -} - -export function shouldRestartStalledReconnect( - status: WsConnectionStatus, - expectedNextRetryAt: string, -): boolean { - return ( - status.reconnectPhase === "waiting" && - status.nextRetryAt === expectedNextRetryAt && - status.online && - status.hasConnected - ); -} - -export function WebSocketConnectionCoordinator() { - const status = useWsConnectionStatus(); - const [nowMs, setNowMs] = useState(() => Date.now()); - const lastForcedReconnectAtRef = useRef(0); - const toastIdRef = useRef | null>(null); - const toastResetTimerRef = useRef(null); - const previousUiStateRef = useRef(getWsConnectionUiState(status)); - const previousDisconnectedAtRef = useRef(status.disconnectedAt); - - const runReconnect = useEffectEvent((showFailureToast: boolean) => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - lastForcedReconnectAtRef.current = Date.now(); - void getPrimaryEnvironmentConnection() - .reconnect() - .catch((error) => { - if (!showFailureToast) { - console.warn("Automatic WebSocket reconnect failed", { error }); - return; - } - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Reconnect failed", - description: - error instanceof Error ? error.message : "Unable to restart the WebSocket.", - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }), - ); - }); - }); - const syncBrowserOnlineStatus = useEffectEvent(() => { - setBrowserOnlineStatus(navigator.onLine !== false); - }); - const triggerManualReconnect = useEffectEvent(() => { - runReconnect(true); - }); - const triggerAutoReconnect = useEffectEvent((trigger: WsAutoReconnectTrigger) => { - const currentStatus = - trigger === "online" ? setBrowserOnlineStatus(true) : getWsConnectionStatus(); - - if (!shouldAutoReconnect(currentStatus, trigger)) { - return; - } - if (Date.now() - lastForcedReconnectAtRef.current < FORCED_WS_RECONNECT_DEBOUNCE_MS) { - return; - } - - runReconnect(false); - }); - - useEffect(() => { - const handleOnline = () => { - triggerAutoReconnect("online"); - }; - const handleFocus = () => { - triggerAutoReconnect("focus"); - }; - - syncBrowserOnlineStatus(); - window.addEventListener("online", handleOnline); - window.addEventListener("offline", syncBrowserOnlineStatus); - window.addEventListener("focus", handleFocus); - return () => { - window.removeEventListener("online", handleOnline); - window.removeEventListener("offline", syncBrowserOnlineStatus); - window.removeEventListener("focus", handleFocus); - }; - }, []); - - useEffect(() => { - if (status.reconnectPhase !== "waiting" || status.nextRetryAt === null) { - return; - } - - setNowMs(Date.now()); - const intervalId = window.setInterval(() => { - setNowMs(Date.now()); - }, 1_000); - - return () => { - window.clearInterval(intervalId); - }; - }, [status.nextRetryAt, status.reconnectPhase]); - - useEffect(() => { - if ( - status.reconnectPhase !== "waiting" || - status.nextRetryAt === null || - !status.online || - !status.hasConnected - ) { - return; - } - - const nextRetryAt = status.nextRetryAt; - const timeoutMs = Math.max(0, new Date(nextRetryAt).getTime() - Date.now()) + 1_500; - const timeoutId = window.setTimeout(() => { - const currentStatus = getWsConnectionStatus(); - if (!shouldRestartStalledReconnect(currentStatus, nextRetryAt)) { - return; - } - - runReconnect(false); - }, timeoutMs); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [ - status.hasConnected, - status.nextRetryAt, - status.online, - status.reconnectAttemptCount, - status.reconnectPhase, - ]); - - useEffect(() => { - const uiState = getWsConnectionUiState(status); - const previousUiState = previousUiStateRef.current; - const previousDisconnectedAt = previousDisconnectedAtRef.current; - const shouldShowReconnectToast = status.hasConnected && uiState === "reconnecting"; - const shouldShowOfflineToast = uiState === "offline" && status.disconnectedAt !== null; - const shouldShowExhaustedToast = status.hasConnected && status.reconnectPhase === "exhausted"; - - if ( - toastResetTimerRef.current !== null && - (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) - ) { - window.clearTimeout(toastResetTimerRef.current); - toastResetTimerRef.current = null; - } - - if (shouldShowReconnectToast || shouldShowOfflineToast || shouldShowExhaustedToast) { - const toastPayload = shouldShowOfflineToast - ? stackedThreadToast({ - data: { - hideCopyButton: true, - }, - description: describeOfflineToast(), - timeout: 0, - title: "Offline", - type: "warning", - }) - : shouldShowExhaustedToast - ? stackedThreadToast({ - actionProps: { - children: "Retry", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: describeExhaustedToast(), - timeout: 0, - title: buildReconnectTitle(status), - type: "error", - }) - : stackedThreadToast({ - actionProps: { - children: "Retry now", - onClick: triggerManualReconnect, - }, - data: { - hideCopyButton: true, - }, - description: - status.nextRetryAt === null - ? `Reconnecting... ${formatReconnectAttemptLabel(status)}` - : `Reconnecting in ${formatRetryCountdown(status.nextRetryAt, nowMs)}... ${formatReconnectAttemptLabel(status)}`, - timeout: 0, - title: buildReconnectTitle(status), - type: "loading", - }); - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, toastPayload); - } else { - toastIdRef.current = toastManager.add(toastPayload); - } - } else if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - - if ( - uiState === "connected" && - (previousUiState === "offline" || previousUiState === "reconnecting") && - previousDisconnectedAt !== null - ) { - const successToast = { - description: describeRecoveredToast(previousDisconnectedAt, status.connectedAt), - title: buildRecoveredTitle(status), - type: "success" as const, - timeout: 0, - data: { - dismissAfterVisibleMs: 8_000, - hideCopyButton: true, - }, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, successToast); - } else { - toastIdRef.current = toastManager.add(successToast); - } - - toastResetTimerRef.current = window.setTimeout(() => { - toastIdRef.current = null; - toastResetTimerRef.current = null; - }, 8_250); - } - - previousUiStateRef.current = uiState; - previousDisconnectedAtRef.current = status.disconnectedAt; - }, [nowMs, status]); - - useEffect(() => { - return () => { - if (toastResetTimerRef.current !== null) { - window.clearTimeout(toastResetTimerRef.current); - } - }; - }, []); - - return null; -} - -export function SlowRpcAckToastCoordinator() { - const slowRequests = useSlowRpcAckRequests(); - const status = useWsConnectionStatus(); - const toastIdRef = useRef | null>(null); - - useEffect(() => { - if (getWsConnectionUiState(status) !== "connected") { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - if (slowRequests.length === 0) { - if (toastIdRef.current) { - toastManager.close(toastIdRef.current); - toastIdRef.current = null; - } - return; - } - - const nextToast = { - data: { - expandableContent: , - expandableDescriptionTrigger: true, - expandableLabels: { collapse: "Hide requests", expand: "Show requests" }, - }, - description: describeSlowRpcAckToast(slowRequests), - timeout: 0, - title: "Some requests are slow", - type: "warning" as const, - }; - - if (toastIdRef.current) { - toastManager.update(toastIdRef.current, nextToast); - } else { - toastIdRef.current = toastManager.add(nextToast); - } - }, [slowRequests, status]); - - return null; -} - -export function WebSocketConnectionSurface({ children }: { readonly children: ReactNode }) { - return children; -} diff --git a/apps/web/src/components/auth/PairingRouteSurface.tsx b/apps/web/src/components/auth/PairingRouteSurface.tsx index 65e9c6dd8eb..8b87c90d104 100644 --- a/apps/web/src/components/auth/PairingRouteSurface.tsx +++ b/apps/web/src/components/auth/PairingRouteSurface.tsx @@ -2,7 +2,7 @@ import type { AuthSessionState } from "@t3tools/contracts"; import React, { startTransition, useEffect, useRef, useState, useCallback } from "react"; import { APP_DISPLAY_NAME } from "../../branding"; -import { addSavedEnvironment } from "../../environments/runtime"; +import { useEnvironmentActions } from "../../state/environments"; import { peekPairingTokenFromUrl, stripPairingTokenFromUrl, @@ -162,6 +162,7 @@ export function PairingRouteSurface({ } export function HostedPairingRouteSurface() { + const { connectPairing } = useEnvironmentActions(); const hostedPairingRequestRef = useRef(readHostedPairingRequest()); const [status, setStatus] = useState<"pairing" | "paired" | "error">(() => hostedPairingRequestRef.current ? "pairing" : "error", @@ -198,13 +199,12 @@ export function HostedPairingRouteSurface() { tokenSubmittedRef.current = true; try { - const record = await addSavedEnvironment({ - label: request.label, + await connectPairing({ host: request.host, pairingCode: request.token, }); setStatus("paired"); - setMessage(`${record.label} is saved in this browser.`); + setMessage(`${request.label || "The environment"} is saved in this browser.`); } catch (error) { tokenSubmittedRef.current = false; setStatus("error"); @@ -213,7 +213,7 @@ export function HostedPairingRouteSurface() { `${errorMessageFromUnknown(error)} If the backend accepted this one-time token, request a new pairing link before retrying.`, ); } - }, []); + }, [connectPairing]); useEffect(() => { if (submitAttemptedRef.current) { diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index 1eca82dbd9b..c371acdb362 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -9,8 +9,8 @@ describe("ChangedFilesTree", () => { { name: "a compacted single-chain directory", files: [ - { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, ], visibleLabels: ["apps/web/src"], hiddenLabels: ["index.ts", "main.ts"], @@ -18,8 +18,18 @@ describe("ChangedFilesTree", () => { { name: "a branch point after a compacted prefix", files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, - { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + { + path: "apps/server/src/git/Layers/GitCore.ts", + kind: "modified", + additions: 4, + deletions: 3, + }, + { + path: "apps/server/src/provider/Layers/CodexAdapter.ts", + kind: "modified", + additions: 7, + deletions: 2, + }, ], visibleLabels: ["apps/server/src"], hiddenLabels: ["git", "provider", "GitCore.ts", "CodexAdapter.ts"], @@ -27,9 +37,14 @@ describe("ChangedFilesTree", () => { { name: "mixed root files and nested compacted directories", files: [ - { path: "README.md", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, + { + path: "packages/contracts/src/orchestration.ts", + kind: "modified", + additions: 13, + deletions: 3, + }, ], visibleLabels: ["README.md", "packages"], hiddenLabels: ["shared/src", "contracts/src", "git.ts", "orchestration.ts"], @@ -60,16 +75,26 @@ describe("ChangedFilesTree", () => { { name: "a compacted single-chain directory", files: [ - { path: "apps/web/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/web/src/main.ts", additions: 3, deletions: 0 }, + { path: "apps/web/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/web/src/main.ts", kind: "modified", additions: 3, deletions: 0 }, ], visibleLabels: ["apps/web/src", "index.ts", "main.ts"], }, { name: "a branch point after a compacted prefix", files: [ - { path: "apps/server/src/git/Layers/GitCore.ts", additions: 4, deletions: 3 }, - { path: "apps/server/src/provider/Layers/CodexAdapter.ts", additions: 7, deletions: 2 }, + { + path: "apps/server/src/git/Layers/GitCore.ts", + kind: "modified", + additions: 4, + deletions: 3, + }, + { + path: "apps/server/src/provider/Layers/CodexAdapter.ts", + kind: "modified", + additions: 7, + deletions: 2, + }, ], visibleLabels: [ "apps/server/src", @@ -82,9 +107,14 @@ describe("ChangedFilesTree", () => { { name: "mixed root files and nested compacted directories", files: [ - { path: "README.md", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, - { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 8, deletions: 2 }, + { + path: "packages/contracts/src/orchestration.ts", + kind: "modified", + additions: 13, + deletions: 3, + }, ], visibleLabels: [ "README.md", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8d89ccdd396..889302ca3d2 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2,6 +2,7 @@ import type { ApprovalRequestId, EnvironmentId, ModelSelection, + PreviewAnnotationPayload, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, @@ -17,6 +18,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; +import { + connectionStatusText, + type EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -53,6 +58,9 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { type ElementContextDraft } from "../../lib/elementContext"; +import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; +import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -400,6 +408,8 @@ export interface ChatComposerHandle { prompt: string; images: ComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -434,7 +444,7 @@ export interface ChatComposerProps { isPreparingWorktree: boolean; environmentUnavailable: { readonly label: string; - readonly connectionState: "connecting" | "disconnected" | "error"; + readonly connection: EnvironmentConnectionPresentation; } | null; // Pending approvals / inputs @@ -486,6 +496,7 @@ export interface ChatComposerProps { promptRef: React.RefObject; composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; + composerElementContextsRef: React.RefObject; composerRef: React.RefObject; // Scroll @@ -576,6 +587,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerRef, composerImagesRef, composerTerminalContextsRef, + composerElementContextsRef, shouldAutoScrollRef, scheduleStickToBottom, onSend, @@ -605,6 +617,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; + const composerElementContexts = composerDraft.elementContexts; + const composerPreviewAnnotations = composerDraft.previewAnnotations; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -620,6 +634,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); + const removeComposerDraftElementContext = useComposerDraftStore( + (store) => store.removeElementContext, + ); + const removeComposerDraftPreviewAnnotation = useComposerDraftStore( + (store) => store.removePreviewAnnotation, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -880,8 +900,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, + elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length, }), - [composerImages.length, composerTerminalContexts, prompt], + [ + composerElementContexts.length, + composerImages.length, + composerPreviewAnnotations.length, + composerTerminalContexts, + prompt, + ], ); // ------------------------------------------------------------------ @@ -1170,6 +1197,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef.current = composerTerminalContexts; }, [composerTerminalContexts, composerTerminalContextsRef]); + useEffect(() => { + composerElementContextsRef.current = composerElementContexts; + }, [composerElementContexts, composerElementContextsRef]); + // ------------------------------------------------------------------ // Composer menu highlight sync // ------------------------------------------------------------------ @@ -1969,6 +2000,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt: promptRef.current, images: composerImagesRef.current, terminalContexts: composerTerminalContextsRef.current, + elementContexts: composerElementContextsRef.current, + previewAnnotations: composerPreviewAnnotations, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -1986,6 +2019,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) promptRef, composerImagesRef, composerTerminalContextsRef, + composerElementContextsRef, + composerPreviewAnnotations, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2226,68 +2261,109 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && - composerImages.length > 0 && ( + composerPreviewAnnotations.length > 0 && ( + + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) + } + onExpandImage={(imageId) => { + const preview = buildExpandedImagePreview(composerImages, imageId); + if (preview) onExpandImage(preview); + }} + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerElementContexts.length > 0 && ( + + removeComposerDraftElementContext(composerDraftTarget, contextId) + } + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.some( + (image) => + !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), + ) && (
- {composerImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))}
)} @@ -2321,11 +2397,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : showPlanFollowUpPrompt && activeProposedPlan ? "Add feedback to refine the plan, or leave this blank to implement it" : environmentUnavailable - ? `${environmentUnavailable.label} is ${ - environmentUnavailable.connectionState === "connecting" - ? "connecting" - : "disconnected" - }` + ? `${environmentUnavailable.label}: ${connectionStatusText( + environmentUnavailable.connection, + )}` : phase === "disconnected" ? "Ask for follow-up changes or attach images" : "Ask anything, @tag files/folders, $use skills, or / for commands" diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 1346d1292ba..afd3a5beff5 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,17 +5,17 @@ import { type ResolvedKeybindingsConfig, type ThreadId, } from "@t3tools/contracts"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { memo } from "react"; import GitActionsControl from "../GitActionsControl"; import { type DraftId } from "~/composerDraftStore"; -import { DiffIcon, TerminalSquareIcon } from "lucide-react"; +import { DiffIcon, PanelRightIcon, TerminalSquareIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; -import { usePrimaryEnvironmentId } from "../../environments/primary"; +import { usePrimaryEnvironment } from "../../state/environments"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -25,12 +25,14 @@ interface ChatHeaderProps { activeProjectName: string | undefined; isGitRepo: boolean; openInCwd: string | null; - activeProjectScripts: ProjectScript[] | undefined; + activeProjectScripts: ReadonlyArray | undefined; preferredScriptId: string | null; keybindings: ResolvedKeybindingsConfig; availableEditors: ReadonlyArray; terminalAvailable: boolean; terminalOpen: boolean; + rightPanelAvailable: boolean; + rightPanelOpen: boolean; terminalToggleShortcutLabel: string | null; diffToggleShortcutLabel: string | null; gitCwd: string | null; @@ -40,6 +42,7 @@ interface ChatHeaderProps { onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onDeleteProjectScript: (scriptId: string) => Promise; onToggleTerminal: () => void; + onToggleRightPanel: () => void; onToggleDiff: () => void; } @@ -69,6 +72,8 @@ export const ChatHeader = memo(function ChatHeader({ availableEditors, terminalAvailable, terminalOpen, + rightPanelAvailable, + rightPanelOpen, terminalToggleShortcutLabel, diffToggleShortcutLabel, gitCwd, @@ -78,9 +83,10 @@ export const ChatHeader = memo(function ChatHeader({ onUpdateProjectScript, onDeleteProjectScript, onToggleTerminal, + onToggleRightPanel, onToggleDiff, }: ChatHeaderProps) { - const primaryEnvironmentId = usePrimaryEnvironmentId(); + const primaryEnvironmentId = usePrimaryEnvironment()?.environmentId ?? null; const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, @@ -104,6 +110,26 @@ export const ChatHeader = memo(function ChatHeader({ /> {activeThreadTitle}
+ + + + + } + /> + + {rightPanelAvailable ? "Toggle browser panel" : "Browser panel is unavailable"} + +
{activeProjectScripts && ( @@ -119,6 +145,7 @@ export const ChatHeader = memo(function ChatHeader({ )} {showOpenInPicker && ( ; + onRemove: (contextId: string) => void; + className?: string; +} + +interface ComposerPendingElementContextChipProps { + context: ElementContextDraft; + onRemove: (contextId: string) => void; +} + +function buildTooltipContent(context: ElementContextDraft): string { + const lines: string[] = []; + lines.push(formatElementContextLabel(context)); + const source = formatElementContextSourceLabel(context); + if (source) lines.push(source); + if (context.pageUrl) lines.push(context.pageUrl); + if (context.selector) lines.push(context.selector); + if (context.htmlPreview.trim().length > 0) { + lines.push(""); + lines.push(context.htmlPreview.trim().slice(0, 600)); + } + return lines.join("\n"); +} + +export function ComposerPendingElementContextChip({ + context, + onRemove, +}: ComposerPendingElementContextChipProps) { + const label = formatElementContextLabel(context); + const sourceLabel = formatElementContextSourceLabel(context); + return ( + + + + {label} + {sourceLabel ? ( + + {sourceLabel} + + ) : null} + + + } + /> + + {buildTooltipContent(context)} + + + ); +} + +export function ComposerPendingElementContexts({ + contexts, + onRemove, + className, +}: ComposerPendingElementContextsProps) { + if (contexts.length === 0) return null; + return ( +
+ {contexts.map((context) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx new file mode 100644 index 00000000000..5bb28054e7d --- /dev/null +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx @@ -0,0 +1,46 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; + +const annotation: PreviewAnnotationPayload = { + id: "annotation_1", + pageUrl: "http://localhost:3000/welcome", + pageTitle: "Welcome", + comment: "Make this headline feel intentional.", + elements: [], + regions: [{ id: "region_1", rect: { x: 1, y: 2, width: 30, height: 20 } }], + strokes: [], + styleChanges: [ + { + targetId: "element_1", + selector: "h1", + property: "font-size", + previousValue: "32px", + value: "40px", + }, + ], + screenshot: null, + createdAt: "2026-06-13T00:00:00.000Z", +}; + +describe("ComposerPreviewAnnotationCards", () => { + it("presents the annotation as one contextual attachment", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Make this headline feel intentional."); + expect(markup).toContain('title="1 region"'); + expect(markup).toContain('title="1 style change"'); + expect(markup).not.toContain("Welcome"); + expect(markup).not.toContain("localhost:3000"); + expect(markup).not.toContain("Preview annotation"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx new file mode 100644 index 00000000000..602ad114464 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -0,0 +1,144 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { Frame, MousePointerClick, Paintbrush, PenLine, X } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { ComposerImageAttachment } from "~/composerDraftStore"; +import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext"; +import { cn } from "~/lib/utils"; + +interface ComposerPreviewAnnotationCardsProps { + annotations: ReadonlyArray; + images: ReadonlyArray; + onRemove: (annotationId: string) => void; + onExpandImage: (imageId: string) => void; + className?: string; +} + +function TargetStat(props: { icon: ReactNode; count: number; label: string }) { + return ( + + {props.icon} + {props.count} + + ); +} + +export function ComposerPreviewAnnotationCards({ + annotations, + images, + onRemove, + onExpandImage, + className, +}: ComposerPreviewAnnotationCardsProps) { + if (annotations.length === 0) return null; + const imagesById = new Map(images.map((image) => [image.id, image])); + + return ( +
+ {annotations.map((annotation) => { + const image = imagesById.get(annotation.id); + const elementLabels = annotation.elements.flatMap((target) => { + const context = normalizeElementContextSelection(target.element); + return context ? [{ id: target.id, label: formatElementContextLabel(context) }] : []; + }); + return ( +
+ {image?.previewUrl ? ( + + ) : ( + + + + )} +
+ {annotation.comment.trim() ? ( +

+ {annotation.comment.trim()} +

+ ) : null} +
+ {elementLabels.length > 0 ? ( +
+ {elementLabels.slice(0, 2).map(({ id, label }) => ( + + {label} + + ))} + {elementLabels.length > 2 ? ( + + +{elementLabels.length - 2} + + ) : null} +
+ ) : null} +
+ {annotation.elements.length > 0 ? ( + } + count={annotation.elements.length} + label="element" + /> + ) : null} + {annotation.regions.length > 0 ? ( + } + count={annotation.regions.length} + label="region" + /> + ) : null} + {annotation.strokes.length > 0 ? ( + } + count={annotation.strokes.length} + label="drawing" + /> + ) : null} + {annotation.styleChanges.length > 0 ? ( + } + count={annotation.styleChanges.length} + label="style change" + /> + ) : null} +
+
+
+ +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index d25cf724923..33c66e0ed13 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -88,7 +88,9 @@ function buildUserTimelineEntry(text: string) { id: "message-1" as never, role: "user" as const, text, + turnId: null, createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, streaming: false, }, }; @@ -103,7 +105,9 @@ function buildAssistantTimelineEntry(text: string) { id: "message-assistant-1" as never, role: "assistant" as const, text, + turnId: null, createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, streaming: false, }, }; @@ -149,7 +153,7 @@ describe("MessagesTimeline", () => { } }); - it("uses accessible tooltips instead of native titles for work entry details", async () => { + it("uses accessible expansion instead of native titles or preview tooltips for work entry details", async () => { const screen = await render( { "Command - git diff -- apps/web/src/components/ChatMarkdown.tsx", ); await commandTrigger.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain( - "git diff -- apps/web/src/components/ChatMarkdown.tsx --stat", - ); - }); + expect(document.querySelector('[data-slot="tooltip-popup"]')).toBeNull(); - const fileTrigger = page.getByLabelText("repo/apps/web/src/components/ChatMarkdown.tsx", { - exact: true, - }); - await fileTrigger.hover(); - await vi.waitFor(() => { - const tooltip = document.querySelector('[data-slot="tooltip-popup"]'); - expect(tooltip?.textContent).toContain("apps/web/src/components/ChatMarkdown.tsx"); - }); + await commandTrigger.click(); + await expect + .element(page.getByText("git diff -- apps/web/src/components/ChatMarkdown.tsx --stat")) + .toBeVisible(); } finally { await screen.unmount(); } @@ -429,7 +424,7 @@ describe("MessagesTimeline", () => { text: "Let me look around first.", turnId: "turn-1" as never, createdAt: "2026-04-13T12:00:00.000Z", - completedAt: "2026-04-13T12:00:02.000Z", + updatedAt: "2026-04-13T12:00:02.000Z", streaming: false, }, }, @@ -456,7 +451,7 @@ describe("MessagesTimeline", () => { text: "All done.", turnId: "turn-1" as never, createdAt: "2026-04-13T12:00:20.000Z", - completedAt: "2026-04-13T12:00:30.000Z", + updatedAt: "2026-04-13T12:00:30.000Z", streaming: false, }, }, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 032f8635698..50ee10b4169 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -14,7 +14,8 @@ describe("computeMessageDurationStart", () => { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:05Z", - completedAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:10Z", + streaming: false, }, ]); expect(result).toEqual(new Map([["a1", "2026-01-01T00:00:05Z"]])); @@ -22,12 +23,19 @@ describe("computeMessageDurationStart", () => { it("uses the user message createdAt for the first assistant response", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, }, ]); @@ -39,20 +47,28 @@ describe("computeMessageDurationStart", () => { ); }); - it("uses the previous assistant completedAt for subsequent assistant responses", () => { + it("uses the previous completed assistant updatedAt for subsequent assistant responses", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, }, { id: "a2", role: "assistant", createdAt: "2026-01-01T00:00:55Z", - completedAt: "2026-01-01T00:00:55Z", + updatedAt: "2026-01-01T00:00:55Z", + streaming: false, }, ]); @@ -65,15 +81,28 @@ describe("computeMessageDurationStart", () => { ); }); - it("does not advance the boundary for a streaming message without completedAt", () => { + it("does not advance the boundary for a streaming message", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, - { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + { + id: "a1", + role: "assistant", + createdAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:40Z", + streaming: true, + }, { id: "a2", role: "assistant", createdAt: "2026-01-01T00:00:55Z", - completedAt: "2026-01-01T00:00:55Z", + updatedAt: "2026-01-01T00:00:55Z", + streaming: false, }, ]); @@ -88,19 +117,33 @@ describe("computeMessageDurationStart", () => { it("resets the boundary on a new user message", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, + }, + { + id: "u2", + role: "user", + createdAt: "2026-01-01T00:01:00Z", + updatedAt: "2026-01-01T00:01:00Z", + streaming: false, }, - { id: "u2", role: "user", createdAt: "2026-01-01T00:01:00Z" }, { id: "a2", role: "assistant", createdAt: "2026-01-01T00:01:20Z", - completedAt: "2026-01-01T00:01:20Z", + updatedAt: "2026-01-01T00:01:20Z", + streaming: false, }, ]); @@ -116,13 +159,26 @@ describe("computeMessageDurationStart", () => { it("handles system messages without affecting the boundary", () => { const result = computeMessageDurationStart([ - { id: "u1", role: "user", createdAt: "2026-01-01T00:00:00Z" }, - { id: "s1", role: "system", createdAt: "2026-01-01T00:00:01Z" }, + { + id: "u1", + role: "user", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + { + id: "s1", + role: "system", + createdAt: "2026-01-01T00:00:01Z", + updatedAt: "2026-01-01T00:00:01Z", + streaming: false, + }, { id: "a1", role: "assistant", createdAt: "2026-01-01T00:00:30Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", + streaming: false, }, ]); @@ -218,6 +274,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Write a poem", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -231,7 +288,7 @@ describe("deriveMessagesTimelineRows", () => { text: "I should ground this first.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -245,7 +302,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Here is the poem.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -280,7 +337,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Earlier response.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -294,7 +351,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Active response.", turnId: "turn-2" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -326,7 +383,9 @@ describe("deriveMessagesTimelineRows", () => { completedAt: "2026-01-01T00:00:30Z", assistantMessageId: "assistant-1" as never, checkpointTurnCount: 2, - files: [{ path: "src/index.ts", additions: 3, deletions: 1 }], + checkpointRef: "checkpoint-1" as never, + status: "ready" as const, + files: [{ path: "src/index.ts", kind: "modified", additions: 3, deletions: 1 }], }; const rows = deriveMessagesTimelineRows({ @@ -341,6 +400,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Do the thing", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -354,7 +414,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -392,6 +452,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Build it", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -405,7 +466,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Looking around first.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:05Z", - completedAt: "2026-01-01T00:00:06Z", + updatedAt: "2026-01-01T00:00:06Z", streaming: false, }, }, @@ -431,7 +492,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:22Z", + updatedAt: "2026-01-01T00:00:22Z", streaming: false, }, }, @@ -451,7 +512,7 @@ describe("deriveMessagesTimelineRows", () => { ); expect(foldRow?.turnId).toBe("turn-1"); expect(foldRow?.expanded).toBe(false); - // User message boundary (00:00:00) → terminal message completedAt (00:00:22). + // User message boundary (00:00:00) → terminal message updatedAt (00:00:22). expect(foldRow?.label).toBe("Worked for 22s"); expect(collapsedRows.map((row) => row.id)).toEqual([ "user-entry", @@ -484,7 +545,7 @@ describe("deriveMessagesTimelineRows", () => { // A steer ends the previous turn early: its only message completes the // instant it is created, and trailing work entries land after it. The // fold duration must span from the user message that started the turn to - // the last entry, not message createdAt → message completedAt (~0ms). + // the last entry, not message createdAt → message updatedAt (~0ms). const rows = deriveMessagesTimelineRows({ timelineEntries: [ { @@ -497,6 +558,7 @@ describe("deriveMessagesTimelineRows", () => { text: "do it once more", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }, }, @@ -510,7 +572,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Kicking off call 1.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:09Z", - completedAt: "2026-01-01T00:00:09Z", + updatedAt: "2026-01-01T00:00:09Z", streaming: false, }, }, @@ -536,6 +598,7 @@ describe("deriveMessagesTimelineRows", () => { text: "actually do 15", turnId: null, createdAt: "2026-01-01T00:00:14Z", + updatedAt: "2026-01-01T00:00:14Z", streaming: false, }, }, @@ -549,6 +612,7 @@ describe("deriveMessagesTimelineRows", () => { text: "One down — adjusting.", turnId: "turn-2" as never, createdAt: "2026-01-01T00:00:17Z", + updatedAt: "2026-01-01T00:00:17Z", streaming: true, }, }, @@ -639,7 +703,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:22Z", + updatedAt: "2026-01-01T00:00:22Z", streaming: false, }, }, @@ -653,6 +717,7 @@ describe("deriveMessagesTimelineRows", () => { text: "yooo", turnId: null, createdAt: "2026-01-01T00:01:00Z", + updatedAt: "2026-01-01T00:01:00Z", streaming: false, }, }, @@ -692,7 +757,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Working on it.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:05Z", - completedAt: "2026-01-01T00:00:06Z", + updatedAt: "2026-01-01T00:00:06Z", streaming: false, }, }, @@ -742,7 +807,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Checking first.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -756,7 +821,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Done.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:20Z", - completedAt: "2026-01-01T00:00:30Z", + updatedAt: "2026-01-01T00:00:30Z", streaming: false, }, }, @@ -789,7 +854,7 @@ describe("deriveMessagesTimelineRows", () => { text: "Working on it.", turnId: "turn-1" as never, createdAt: "2026-01-01T00:00:10Z", - completedAt: "2026-01-01T00:00:11Z", + updatedAt: "2026-01-01T00:00:11Z", streaming: false, }, }, @@ -824,6 +889,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "First", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }; const secondUserMessage = { @@ -832,6 +898,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "Second", turnId: null, createdAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:10Z", streaming: false, }; @@ -927,6 +994,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "First", turnId: null, createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", streaming: false, }; const secondUserMessage = { @@ -935,6 +1003,7 @@ describe("computeStableMessagesTimelineRows", () => { text: "Second", turnId: null, createdAt: "2026-01-01T00:00:10Z", + updatedAt: "2026-01-01T00:00:10Z", streaming: false, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 416b37e4f51..1426f1deee2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -26,7 +26,8 @@ export interface TimelineDurationMessage { id: string; role: "user" | "assistant" | "system"; createdAt: string; - completedAt?: string | undefined; + updatedAt: string; + streaming: boolean; } export type TimelineLatestTurn = Pick< @@ -85,8 +86,8 @@ export function computeMessageDurationStart( lastBoundary = message.createdAt; } result.set(message.id, lastBoundary ?? message.createdAt); - if (message.role === "assistant" && message.completedAt) { - lastBoundary = message.completedAt; + if (message.role === "assistant" && !message.streaming) { + lastBoundary = message.updatedAt; } } @@ -256,9 +257,7 @@ function deriveTurnFolds(input: { // A turn cut short by a steer leaves trailing work entries behind its // terminal message — take whichever ended last. const lastEntryEnd = - lastEntry.kind === "message" - ? (lastEntry.message.completedAt ?? lastEntry.createdAt) - : lastEntry.createdAt; + lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt; const elapsedMs = input.latestTurn?.turnId === turnId && input.latestTurn.startedAt && @@ -266,7 +265,7 @@ function deriveTurnFolds(input: { ? computeElapsedMs(input.latestTurn.startedAt, input.latestTurn.completedAt) : computeElapsedMs( group.startBoundary ?? firstEntry.createdAt, - maxIsoTimestamp(group.terminalEntry?.message.completedAt ?? null, lastEntryEnd) ?? + maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ?? lastEntryEnd, ); const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 743de5aa6ca..f1cf01d82b0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -128,7 +128,9 @@ function buildUserTimelineEntry(text: string) { id: MessageId.make("message-1"), role: "user" as const, text, + turnId: null, createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, streaming: false, }, }; @@ -280,7 +282,9 @@ describe("MessagesTimeline", () => { "```", "", ].join("\n"), + turnId: null, createdAt: "2026-03-17T19:12:28.000Z", + updatedAt: "2026-03-17T19:12:28.000Z", streaming: false, }, }, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7e4a402d910..515e32e0f37 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,9 +1,11 @@ import { type EnvironmentId, type MessageId, + type ScopedThreadRef, type ServerProviderSkill, type TurnId, } from "@t3tools/contracts"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { createContext, Fragment, @@ -11,6 +13,7 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -44,8 +47,9 @@ import { EyeIcon, GlobeIcon, HammerIcon, - type LucideIcon, MessageCircleIcon, + MousePointerClickIcon, + PaintbrushIcon, MinusIcon, SquarePenIcon, TerminalIcon, @@ -76,6 +80,14 @@ import { deriveDisplayedUserMessageState, type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; +import { + extractTrailingElementContexts, + type ParsedElementContextEntry, +} from "~/lib/elementContext"; +import { + extractTrailingPreviewAnnotation, + type ParsedPreviewAnnotation, +} from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; @@ -104,6 +116,7 @@ import { interface TimelineRowSharedState { timestampFormat: TimestampFormat; routeThreadKey: string; + threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; @@ -300,6 +313,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ timestampFormat, routeThreadKey, + threadRef: parseScopedThreadKey(routeThreadKey), markdownCwd, resolvedTheme, workspaceRoot, @@ -396,7 +410,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time className={cn( // Commentary (non-terminal assistant) rows carry no metadata row, so // they sit closer to the work that follows them. - row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta + (row.kind === "message" && row.message.role === "assistant" && !row.showAssistantMeta) || + row.kind === "work" ? "pb-2" : "pb-4", row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, @@ -423,14 +438,25 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); + const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return (
- {userImages.length > 0 && ( + {regularImages.length > 0 && (
- {userImages.map((image: NonNullable[number]) => ( + {regularImages.map((image: NonNullable[number]) => (
{ - const preview = buildExpandedImagePreview(userImages, image.id); + const preview = buildExpandedImagePreview(regularImages, image.id); if (!preview) return; ctx.onImageExpand(preview); }} @@ -461,8 +487,25 @@ function UserTimelineRow({ row }: { row: Extract )} + {previewAnnotations.map((annotation, index) => ( + + ))} + {elementContextState.contexts.length > 0 ? ( +
+ {elementContextState.contexts.map((context) => ( + + ))} +
+ ) : null} ctx.onToggleTurnFold(row.turnId)} - className="flex items-center gap-1 px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground" + className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" > {row.label} @@ -545,6 +588,7 @@ function AssistantTimelineRow({ row }: { row: Extract @@ -562,16 +606,10 @@ function AssistantTimelineRow({ row }: { row: Extract} > - {formatShortTimestamp( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} - {formatChatTimestampTooltip( - row.message.completedAt ?? row.message.createdAt, - ctx.timestampFormat, - )} + {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} )} @@ -608,6 +646,7 @@ function ProposedPlanTimelineRow({ @@ -679,6 +718,8 @@ const WorkGroupSection = memo(function WorkGroupSection({ }) { const { workspaceRoot } = use(TimelineRowCtx); const [isExpanded, setIsExpanded] = useState(false); + const sectionRef = useRef(null); + const anchorBottomBeforeToggleRef = useRef(null); const nonEmptyEntries = useMemo( () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), [groupedEntries], @@ -690,39 +731,54 @@ const WorkGroupSection = memo(function WorkGroupSection({ : nonEmptyEntries; const hiddenCount = nonEmptyEntries.length - visibleEntries.length; const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry)); - const headerTitle = onlyToolEntries + const groupLabel = onlyToolEntries ? nonEmptyEntries.length === 1 ? "1 tool call" : `${nonEmptyEntries.length} tool calls` : "work log"; + useLayoutEffect(() => { + const anchorBottomBeforeToggle = anchorBottomBeforeToggleRef.current; + anchorBottomBeforeToggleRef.current = null; + + if (anchorBottomBeforeToggle === null) { + return; + } + + const section = sectionRef.current; + if (!section) { + return; + } + + const delta = section.getBoundingClientRect().bottom - anchorBottomBeforeToggle; + if (Math.abs(delta) < 0.5) { + return; + } + + const scroller = findNearestVerticalScroller(section); + if (scroller) { + scroller.scrollTop += delta; + } else { + window.scrollBy(0, delta); + } + }, [isExpanded]); + + const toggleExpanded = () => { + anchorBottomBeforeToggleRef.current = + sectionRef.current?.getBoundingClientRect().bottom ?? null; + setIsExpanded((v) => !v); + }; + if (nonEmptyEntries.length === 0) return null; return ( -
-
-

{headerTitle}

- {hasOverflow && ( - - )} -
-
+
+ {!onlyToolEntries && ( +

+ {groupLabel} +

+ )} +
{visibleEntries.map((workEntry) => ( ))}
-
+ {hasOverflow && ( + + )} + ); }); +function findNearestVerticalScroller(element: HTMLElement): HTMLElement | null { + let parent = element.parentElement; + while (parent) { + const { overflowY } = window.getComputedStyle(parent); + if ( + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && + parent.scrollHeight > parent.clientHeight + ) { + return parent; + } + parent = parent.parentElement; + } + return null; +} + /** Subscribes directly to the UI state store for expand/collapse state, * so toggling re-renders only this component — not the entire list. */ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ @@ -844,6 +937,81 @@ const UserMessageTerminalContextInlineLabel = memo( }, ); +const UserMessageElementContextChip = memo(function UserMessageElementContextChip(props: { + context: ParsedElementContextEntry; +}) { + const tooltipText = props.context.body + ? `${props.context.header}\n${props.context.body}` + : props.context.header; + return ( + + + + {props.context.header} + + } + /> + + {tooltipText} + + + ); +}); + +function UserMessagePreviewAnnotationCard(props: { + annotation: ParsedPreviewAnnotation; + image: NonNullable[number] | null; +}) { + const ctx = use(TimelineRowCtx); + return ( +
+ {props.image?.previewUrl ? ( + + ) : null} +
+ {props.annotation.comment ? ( +
+ {props.annotation.comment} +
+ ) : null} +
+ {props.annotation.targetSummary ? ( + {props.annotation.targetSummary} + ) : null} + {props.annotation.styleChanges.length > 0 ? ( + + + {props.annotation.styleChanges.length} + + ) : null} +
+
+
+ ); +} + const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; @@ -934,6 +1102,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills: ReadonlyArray>; markdownCwd: string | undefined; }) { + const ctx = use(TimelineRowCtx); const renderInlineMarkdownSegment = (text: string, key: string) => { const leadingWhitespace = /^\s+/.exec(text)?.[0] ?? ""; const textWithoutLeadingWhitespace = text.slice(leadingWhitespace.length); @@ -950,6 +1119,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { ; + case "check": + return ; + case "circle-alert": + return ; + case "eye": + return ; + case "globe": + return ; + case "hammer": + return ; + case "message-circle": + return ; + case "square-pen": + return ; + case "terminal": + return ; + case "wrench": + return ; + case "x": + return ; + case "zap": + return ; + } +} + function workToneIcon(tone: TimelineWorkEntry["tone"]): { - icon: LucideIcon; + iconName: WorkEntryIconName; className: string; } { if (tone === "error") { return { - icon: CircleAlertIcon, + iconName: "circle-alert", className: "text-foreground/92", }; } if (tone === "thinking") { return { - icon: BotIcon, + iconName: "bot", className: "text-foreground/92", }; } if (tone === "info") { return { - icon: CheckIcon, + iconName: "check", className: "text-muted-foreground", }; } return { - icon: ZapIcon, + iconName: "zap", className: "text-foreground/92", }; } @@ -1236,52 +1452,63 @@ function workEntryRawCommand( return rawCommand === workEntry.command.trim() ? null : rawCommand; } -function buildToolCallExpandedBody(workEntry: TimelineWorkEntry): string | null { +function buildToolCallExpandedBody( + workEntry: TimelineWorkEntry, + workspaceRoot: string | undefined, +): string | null { const blocks: string[] = []; - if (workEntry.detail?.trim()) { - blocks.push(workEntry.detail.trim()); + if (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) { + blocks.push(`MCP call\n${JSON.stringify(workEntry.toolData, null, 2)}`); } const raw = workEntryRawCommand(workEntry); if (raw?.trim()) { - blocks.push(`Full command\n${raw.trim()}`); + blocks.push(raw.trim()); } else if (workEntry.command?.trim()) { - blocks.push(`Command\n${workEntry.command.trim()}`); + blocks.push(workEntry.command.trim()); } - if (blocks.length === 0) { - return null; + if (workEntry.detail?.trim()) { + blocks.push(workEntry.detail.trim()); + } + const changedFiles = workEntry.changedFiles ?? []; + if (changedFiles.length > 0) { + blocks.push( + changedFiles + .map((filePath) => formatWorkspaceRelativePath(filePath, workspaceRoot)) + .join("\n"), + ); } - return blocks.join("\n\n"); + return blocks.length > 0 ? blocks.join("\n\n") : null; } -function workEntryIcon(workEntry: TimelineWorkEntry): LucideIcon { +function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || workEntry.sourceActivityKind === "user-input.resolved" ) { - return MessageCircleIcon; + return "message-circle"; } - if (workEntry.requestKind === "command") return TerminalIcon; - if (workEntry.requestKind === "file-read") return EyeIcon; - if (workEntry.requestKind === "file-change") return SquarePenIcon; + if (workEntry.requestKind === "command") return "terminal"; + if (workEntry.requestKind === "file-read") return "eye"; + if (workEntry.requestKind === "file-change") return "square-pen"; if (workEntry.itemType === "command_execution" || workEntry.command) { - return TerminalIcon; + return "terminal"; } if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { - return SquarePenIcon; + return "square-pen"; } - if (workEntry.itemType === "web_search") return GlobeIcon; - if (workEntry.itemType === "image_view") return EyeIcon; + if (workEntry.itemType === "web_search") return "globe"; + if (workEntry.itemType === "image_view") return "eye"; switch (workEntry.itemType) { case "mcp_tool_call": - return WrenchIcon; + return "wrench"; case "dynamic_tool_call": case "collab_agent_tool_call": - return HammerIcon; + return "hammer"; } - return workToneIcon(workEntry.tone).icon; + return workToneIcon(workEntry.tone).iconName; } function capitalizePhrase(value: string): string { @@ -1310,7 +1537,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const EntryIcon = showWarningIndicator ? XIcon : workEntryIcon(workEntry); + const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); const heading = toolWorkEntryHeading(workEntry); const rawPreview = workEntryPreview(workEntry, workspaceRoot); const preview = @@ -1319,11 +1546,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { normalizeCompactToolLabel(heading).toLowerCase() ? null : rawPreview; - const rawCommand = workEntryRawCommand(workEntry); const displayText = preview ? `${heading} - ${preview}` : heading; - const expandedBody = buildToolCallExpandedBody(workEntry); + const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const canExpand = expandedBody !== null; - const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const showFailedIndicator = workEntryIndicatesToolFailure(workEntry); const showDestructiveRowStyle = showFailedIndicator && @@ -1335,14 +1560,14 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { : showDestructiveRowStyle ? "text-destructive" : workEntry.tone === "tool" || showFailedIndicator - ? "text-muted-foreground" + ? "text-muted-foreground/65" : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" : showDestructiveRowStyle ? "font-medium text-destructive" - : "font-medium text-foreground"; + : "font-medium text-foreground/82"; const turnSettled = !activity.activeTurnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = @@ -1352,6 +1577,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? { role: "button" as const, tabIndex: 0 as const, + "aria-label": displayText, onClick: () => setExpanded((v) => !v), onKeyDown: (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -1365,114 +1591,66 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { return (
-
+
- + -
+
- {rawCommand ? ( -
- - - } - > - {heading} - {preview && ( - - {preview} - - )} - - -
- {rawCommand} -
-
-
-
- ) : ( - - -

- {heading} - {preview && ( - - {preview} - - )} -

-
- -

- {displayText} -

-
-
- )} +

+ {heading} + {preview && ( + {preview} + )} +

-
+
{canExpand ? ( ) : null} - + {showFailedIndicator ? ( } > - + Failed ) : showSuccessIndicator ? ( } + render={} > - + @@ -1483,12 +1661,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ) : showNeutralIndicator ? ( } + render={} > - + Empty @@ -1499,48 +1674,15 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
{expanded && canExpand && expandedBody ? (
-
+          
             {expandedBody}
           
) : null} - {hasChangedFiles && ( -
- {workEntry.changedFiles?.slice(0, 4).map((filePath) => { - const displayPath = formatWorkspaceRelativePath(filePath, workspaceRoot); - return ( - - - } - > - {displayPath} - - - {displayPath} - - - ); - })} - {(workEntry.changedFiles?.length ?? 0) > 4 && ( - - +{(workEntry.changedFiles?.length ?? 0) - 4} - - )} -
- )}
); }); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index cc023d34cfb..31268c441a1 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,4 +1,5 @@ -import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; @@ -32,7 +33,7 @@ import { WebStormIcon, } from "../JetBrainsIcons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; -import { readLocalApi } from "~/localApi"; +import { shellEnvironment } from "~/state/shell"; const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ @@ -151,14 +152,19 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray; openInCwd: string | null; }) { + const openInEditorMutation = useAtomSet(shellEnvironment.openInEditor, { + mode: "promise", + }); const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( () => resolveOptions(navigator.platform, availableEditors), @@ -168,14 +174,19 @@ export const OpenInPicker = memo(function OpenInPicker({ const openInEditor = useCallback( (editorId: EditorId | null) => { - const api = readLocalApi(); - if (!api || !openInCwd) return; + if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); + void openInEditorMutation({ + environmentId, + input: { + cwd: openInCwd, + editor, + }, + }); setPreferredEditor(editor); }, - [preferredEditor, openInCwd, setPreferredEditor], + [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -185,17 +196,22 @@ export const OpenInPicker = memo(function OpenInPicker({ useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { - const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; - if (!api || !openInCwd) return; + if (!openInCwd) return; if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, preferredEditor); + void openInEditorMutation({ + environmentId, + input: { + cwd: openInCwd, + editor: preferredEditor, + }, + }); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [preferredEditor, keybindings, openInCwd]); + }, [environmentId, keybindings, openInCwd, openInEditorMutation, preferredEditor]); return ( diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index e53ee93b913..84fa1bbb856 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -1,5 +1,6 @@ import { memo, useState, useId } from "react"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; import { buildCollapsedProposedPlanPreviewMarkdown, buildProposedPlanMarkdownFilename, @@ -25,17 +26,19 @@ import { DialogTitle, } from "../ui/dialog"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { readEnvironmentApi } from "~/environmentApi"; +import { projectEnvironment } from "~/state/projects"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; export const ProposedPlanCard = memo(function ProposedPlanCard({ planMarkdown, environmentId, + threadRef, cwd, workspaceRoot, }: { planMarkdown: string; environmentId: EnvironmentId; + threadRef?: ScopedThreadRef | undefined; cwd: string | undefined; workspaceRoot: string | undefined; }) { @@ -43,6 +46,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false); const [savePath, setSavePath] = useState(""); const [isSavingToWorkspace, setIsSavingToWorkspace] = useState(false); + const writeProjectFile = useAtomSet(projectEnvironment.writeFile, { mode: "promise" }); const { copyToClipboard, isCopied } = useCopyToClipboard({ onError: (error) => { toastManager.add( @@ -89,9 +93,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }; const handleSaveToWorkspace = () => { - const api = readEnvironmentApi(environmentId); const relativePath = savePath.trim(); - if (!api || !workspaceRoot) { + if (!workspaceRoot) { return; } if (!relativePath) { @@ -103,12 +106,14 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ } setIsSavingToWorkspace(true); - void api.projects - .writeFile({ + void writeProjectFile({ + environmentId, + input: { cwd: workspaceRoot, relativePath, contents: saveContents, - }) + }, + }) .then((result) => { setIsSaveDialogOpen(false); toastManager.add({ @@ -163,9 +168,19 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
{canCollapse && !expanded ? ( - + ) : ( - + )} {canCollapse && !expanded ? (
diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 1952d77d4f4..484080013c6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,5 +1,4 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; -import { EnvironmentId } from "@t3tools/contracts"; import { createModelCapabilities } from "@t3tools/shared/model"; import { page, userEvent } from "vite-plus/test/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -19,63 +18,6 @@ import { } from "@t3tools/contracts/settings"; import { __resetLocalApiForTests } from "../../localApi"; -// Mock the environments/runtime module to provide a mock primary environment connection -vi.mock("../../environments/runtime", () => { - const primaryConnection = { - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Local environment", - source: "manual" as const, - environmentId: EnvironmentId.make("environment-local"), - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - }, - environmentId: EnvironmentId.make("environment-local"), - client: { - server: { - getConfig: vi.fn(), - updateSettings: vi.fn(), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }; - - return { - getEnvironmentHttpBaseUrl: () => "http://localhost:3000", - getSavedEnvironmentRecord: () => null, - getSavedEnvironmentRuntimeState: () => null, - hasSavedEnvironmentRegistryHydrated: () => true, - listSavedEnvironmentRecords: () => [], - resetSavedEnvironmentRegistryStoreForTests: vi.fn(), - resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), - resolveEnvironmentHttpUrl: (_environmentId: unknown, path: string) => - new URL(path, "http://localhost:3000").toString(), - waitForSavedEnvironmentRegistryHydration: async () => undefined, - addSavedEnvironment: vi.fn(), - disconnectSavedEnvironment: vi.fn(), - ensureEnvironmentConnectionBootstrapped: async () => undefined, - getPrimaryEnvironmentConnection: () => primaryConnection, - readEnvironmentConnection: () => primaryConnection, - reconnectSavedEnvironment: vi.fn(), - removeSavedEnvironment: vi.fn(), - requireEnvironmentConnection: () => primaryConnection, - resetEnvironmentServiceForTests: vi.fn(), - startEnvironmentConnectionService: vi.fn(), - subscribeEnvironmentConnections: () => () => {}, - useSavedEnvironmentRegistryStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - useSavedEnvironmentRuntimeStore: ( - selector: (state: { byId: Record }) => unknown, - ) => selector({ byId: {} }), - }; -}); - function selectDescriptor( id: string, label: string, diff --git a/apps/web/src/components/preview/AgentBrowserCursor.tsx b/apps/web/src/components/preview/AgentBrowserCursor.tsx new file mode 100644 index 00000000000..2f12300400d --- /dev/null +++ b/apps/web/src/components/preview/AgentBrowserCursor.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { MousePointer2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { useBrowserPointerStore } from "~/browser/browserPointerStore"; + +import { agentBrowserCursorOpacity, type BrowserController } from "./agentBrowserCursorLogic"; + +const CURSOR_ACTIVE_MS = 700; + +export function AgentBrowserCursor(props: { + readonly tabId: string; + readonly zoomFactor: number; + readonly controller: BrowserController; +}) { + const { tabId, zoomFactor, controller } = props; + const event = useBrowserPointerStore((state) => state.byTabId[tabId] ?? null); + const [active, setActive] = useState(false); + + useEffect(() => { + if (!event) return; + setActive(true); + const timeout = window.setTimeout(() => setActive(false), CURSOR_ACTIVE_MS); + return () => window.clearTimeout(timeout); + }, [event]); + + if (!event) return null; + + return ( + + ); +} diff --git a/apps/web/src/components/preview/BrowserMockup.tsx b/apps/web/src/components/preview/BrowserMockup.tsx new file mode 100644 index 00000000000..3b1882bbda9 --- /dev/null +++ b/apps/web/src/components/preview/BrowserMockup.tsx @@ -0,0 +1,24 @@ +import { cn } from "~/lib/utils"; + +/** Browser-window thumbnail glyph for the "Local" recommendation cards. */ +export function BrowserMockup({ className }: { className?: string }) { + return ( +
+
+ + + +
+
+ + +
+
+ ); +} diff --git a/apps/web/src/components/preview/PreviewAutomationOwner.tsx b/apps/web/src/components/preview/PreviewAutomationOwner.tsx new file mode 100644 index 00000000000..740e11ccf8c --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationOwner.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { + PreviewAutomationNavigateInput, + PreviewAutomationOpenInput, + PreviewAutomationRequest, + PreviewAutomationResponse, + PreviewAutomationStatus, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { useCallback, useEffect, useId, useRef } from "react"; + +import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; +import { + startBrowserRecording, + stopBrowserRecording, + useBrowserRecordingStore, +} from "~/browser/browserRecording"; +import { previewEnvironment, usePreviewActions } from "~/state/preview"; +import { useEnvironmentQuery } from "~/state/query"; + +import { previewBridge } from "./previewBridge"; + +const waitForDesktopOverlay = async ( + threadRef: ScopedThreadRef, + timeoutMs: number, +): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); + const tabId = state.snapshot?.tabId; + if (tabId && state.desktopOverlay && previewBridge) { + const status = await previewBridge.automation.status(tabId); + if (status.available) return; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`); + error.name = "PreviewAutomationTimeoutError"; + throw error; +}; + +const waitForNavigationReadiness = async ( + tabId: string, + readiness: PreviewAutomationNavigateInput["readiness"], + timeoutMs: number, +): Promise => { + if (!previewBridge || readiness === "none") return; + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + if (readiness === "domContentLoaded") { + const readyState = await previewBridge.automation.evaluate(tabId, { + expression: "document.readyState", + }); + if (readyState === "interactive" || readyState === "complete") return; + } else { + const status = await previewBridge.automation.status(tabId); + if (!status.loading) return; + } + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`); + error.name = "PreviewAutomationTimeoutError"; + throw error; +}; + +const currentStatus = async ( + threadRef: ScopedThreadRef, + visible: boolean, +): Promise => { + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); + const tabId = state.snapshot?.tabId ?? null; + if (tabId && previewBridge && state.desktopOverlay) { + const status = await previewBridge.automation.status(tabId); + return { ...status, visible }; + } + const navStatus = state.snapshot?.navStatus; + return { + available: Boolean(previewBridge?.automation), + visible, + tabId, + url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, + title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, + loading: navStatus?._tag === "Loading", + }; +}; + +const serializeError = (error: unknown): NonNullable => { + if (error instanceof Error) { + const detail = + "detail" in error && (error as { detail?: unknown }).detail !== undefined + ? (error as { detail?: unknown }).detail + : undefined; + return { + _tag: error.name.startsWith("PreviewAutomation") + ? error.name + : "PreviewAutomationExecutionError", + message: error.message, + ...(detail === undefined ? {} : { detail }), + }; + } + return { + _tag: "PreviewAutomationExecutionError", + message: String(error), + }; +}; + +export function PreviewAutomationOwner(props: { + readonly threadRef: ScopedThreadRef; + readonly visible: boolean; +}) { + const { threadRef, visible } = props; + const automationClientId = useId(); + const automationRequests = useEnvironmentQuery( + previewEnvironment.automationRequests({ + environmentId: threadRef.environmentId, + input: { clientId: automationClientId }, + }), + ); + const { open, respondToAutomation, reportAutomationOwner, clearAutomationOwner } = + usePreviewActions(); + const ownerStateRef = useRef({ threadRef, visible }); + const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise>( + async () => undefined, + ); + useEffect(() => { + ownerStateRef.current = { threadRef, visible }; + }, [threadRef, visible]); + + const handleRequest = useCallback( + async (request: PreviewAutomationRequest): Promise => { + if (request.threadId !== threadRef.threadId) { + const error = new Error("Preview automation request targeted a stale thread owner."); + error.name = "PreviewAutomationUnavailableError"; + throw error; + } + const state = selectThreadPreviewState( + usePreviewStateStore.getState().byThreadKey, + threadRef, + ); + const tabId = request.tabId ?? state.snapshot?.tabId ?? null; + switch (request.operation) { + case "status": + return currentStatus(threadRef, visible); + case "open": { + const input = request.input as PreviewAutomationOpenInput; + let activeTabId = + (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; + if (!activeTabId) { + const snapshot = await open({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + ...(input.url ? { url: input.url } : {}), + }, + }); + usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); + activeTabId = snapshot.tabId; + } else if (input.url && previewBridge) { + await previewBridge.navigate(activeTabId, input.url); + } + if (input.show ?? true) { + useRightPanelStore.getState().openBrowser(threadRef, activeTabId); + } + await waitForDesktopOverlay(threadRef, request.timeoutMs); + return currentStatus(threadRef, input.show ?? true); + } + case "navigate": { + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + const input = request.input as PreviewAutomationNavigateInput; + const resolution = resolveBrowserNavigationTarget( + threadRef.environmentId, + input.target ?? { kind: "url", url: input.url! }, + ); + await previewBridge.navigate(tabId, resolution.resolvedUrl); + await waitForNavigationReadiness( + tabId, + input.readiness ?? "load", + input.timeoutMs ?? request.timeoutMs, + ); + return currentStatus(threadRef, visible); + } + case "snapshot": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.snapshot(tabId); + case "click": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.click( + tabId, + request.input as Parameters[1], + ); + case "type": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.type( + tabId, + request.input as Parameters[1], + ); + case "press": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.press( + tabId, + request.input as Parameters[1], + ); + case "scroll": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.scroll( + tabId, + request.input as Parameters[1], + ); + case "evaluate": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.evaluate( + tabId, + request.input as Parameters[1], + ); + case "waitFor": + if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); + return previewBridge.automation.waitFor( + tabId, + request.input as Parameters[1], + ); + case "recordingStart": { + if (!tabId) throw new Error("Preview tab is not initialized."); + await startBrowserRecording(tabId); + return { + tabId, + recording: true, + startedAt: useBrowserRecordingStore.getState().startedAt, + }; + } + case "recordingStop": { + if (!tabId) throw new Error("Preview tab is not initialized."); + const artifact = await stopBrowserRecording(tabId); + if (!artifact) throw new Error("No active recording exists for this preview tab."); + return artifact; + } + } + }, + [open, threadRef, visible], + ); + useEffect(() => { + handlerRef.current = handleRequest; + }, [handleRequest]); + + useEffect(() => { + const request = automationRequests.data; + if (!request) return; + void handlerRef.current(request).then( + (result) => + respondToAutomation({ + environmentId: threadRef.environmentId, + input: { + requestId: request.requestId, + ok: true, + ...(result === undefined ? {} : { result }), + }, + }), + (error) => + respondToAutomation({ + environmentId: threadRef.environmentId, + input: { + requestId: request.requestId, + ok: false, + error: serializeError(error), + }, + }), + ); + }, [automationRequests.data, respondToAutomation, threadRef.environmentId]); + + useEffect(() => { + const report = () => { + const state = selectThreadPreviewState( + usePreviewStateStore.getState().byThreadKey, + threadRef, + ); + void reportAutomationOwner({ + environmentId: threadRef.environmentId, + input: { + clientId: automationClientId, + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + tabId: state.snapshot?.tabId ?? null, + visible, + supportsAutomation: Boolean(previewBridge?.automation), + focusedAt: new Date().toISOString(), + }, + }); + }; + report(); + window.addEventListener("focus", report); + const unsubscribe = usePreviewStateStore.subscribe((state, previous) => { + const key = scopedThreadKey(threadRef); + if (state.byThreadKey[key]?.snapshot?.tabId !== previous.byThreadKey[key]?.snapshot?.tabId) { + report(); + } + }); + return () => { + window.removeEventListener("focus", report); + unsubscribe(); + void clearAutomationOwner({ + environmentId: threadRef.environmentId, + input: { clientId: automationClientId }, + }); + }; + }, [automationClientId, clearAutomationOwner, reportAutomationOwner, threadRef, visible]); + + return null; +} diff --git a/apps/web/src/components/preview/PreviewChromeRow.browser.tsx b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx new file mode 100644 index 00000000000..d92839b55fa --- /dev/null +++ b/apps/web/src/components/preview/PreviewChromeRow.browser.tsx @@ -0,0 +1,75 @@ +import "../../index.css"; + +import { page } from "vite-plus/test/browser"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { PreviewChromeRow } from "./PreviewChromeRow"; + +const defaultProps = { + url: "https://example.com/", + loading: false, + loadProgress: 0, + canGoBack: false, + canGoForward: false, + refreshDisabled: false, + onBack: vi.fn(), + onForward: vi.fn(), + onRefresh: vi.fn(), + onSubmit: vi.fn(), +}; + +describe("PreviewChromeRow", () => { + it("only focuses the URL input after an explicit focus request", async () => { + const previouslyFocused = document.createElement("button"); + document.body.append(previouslyFocused); + previouslyFocused.focus(); + + const screen = await render(); + const input = page.getByRole("textbox").element() as HTMLInputElement; + + expect(document.activeElement).toBe(previouslyFocused); + + await screen.rerender(); + + expect(document.activeElement).toBe(input); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + + previouslyFocused.remove(); + }); + + it("shows a friendly asset label until the URL input receives focus", async () => { + const fullUrl = "http://127.0.0.1:3773/api/assets/token/report.pdf"; + await render( + , + ); + const input = page.getByRole("textbox"); + + await expect.element(input).toHaveValue("Local environment · report.pdf"); + + await input.click(); + + await expect.element(input).toHaveValue(fullUrl); + + input.element().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + + await expect.element(input).toHaveValue("Local environment · report.pdf"); + }); + + it("shows only the host for regular URLs until the input receives focus", async () => { + const fullUrl = "https://t3.chat/chat/18378834-f776-4507-ada7-6f79"; + await render(); + const input = page.getByRole("textbox"); + + await expect.element(input).toHaveValue("t3.chat"); + + await input.click(); + + await expect.element(input).toHaveValue(fullUrl); + }); +}); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx new file mode 100644 index 00000000000..057db6d801f --- /dev/null +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -0,0 +1,301 @@ +import { + ArrowLeft, + ArrowRight, + Camera, + ExternalLink, + MousePointerClick, + RotateCw, +} from "lucide-react"; +import { + type FormEvent, + type KeyboardEvent, + type ReactNode, + useEffect, + useRef, + useState, +} from "react"; + +import { Button } from "~/components/ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { cn } from "~/lib/utils"; + +interface Props { + url: string; + displayUrl?: string | undefined; + loading: boolean; + loadProgress: number; + canGoBack: boolean; + canGoForward: boolean; + refreshDisabled: boolean; + inputDisabled?: boolean | undefined; + /** Bumping this value re-focuses and selects the URL input. */ + focusUrlNonce?: number | undefined; + onBack: () => void; + onForward: () => void; + onRefresh: () => void; + onSubmit: (url: string) => void; + /** When provided, renders an "Open in browser" affordance to the right. */ + onOpenInBrowser?: (() => void) | undefined; + onCapture?: ((record: boolean) => void) | undefined; + captureDisabled?: boolean | undefined; + recording?: boolean | undefined; + /** + * When provided, renders an annotation-mode toggle button to the right of + * the URL input. Pressed while annotation mode is active (button shows in `pressed` + * state). Disabled in `pickDisabled` mode. + */ + onPickElement?: (() => void) | undefined; + pickActive?: boolean | undefined; + pickDisabled?: boolean | undefined; + /** Optional reason string surfaced in the disabled tooltip. */ + pickDisabledReason?: string | undefined; + /** + * Trailing slot rendered after the URL input. Used by the preview view + * to mount the three-dot menu (hard reload, devtools, zoom, clear data). + */ + trailingActions?: ReactNode; +} + +const NOOP = () => {}; + +export function PreviewChromeRow({ + url, + displayUrl, + loading, + loadProgress, + canGoBack, + canGoForward, + refreshDisabled, + inputDisabled, + focusUrlNonce, + onBack, + onForward, + onRefresh, + onSubmit, + onOpenInBrowser, + onCapture, + captureDisabled, + recording, + onPickElement, + pickActive, + pickDisabled, + pickDisabledReason, + trailingActions, +}: Props) { + const inputRef = useRef(null); + const [draft, setDraft] = useState(url); + const [inputFocused, setInputFocused] = useState(false); + + // Sync the input with external URL changes, but only when the user isn't + // actively typing (preserves in-progress edits during navigation events). + useEffect(() => { + setDraft((previous) => (document.activeElement === inputRef.current ? previous : url)); + }, [url]); + + useEffect(() => { + if (focusUrlNonce == null) return; + const node = inputRef.current; + if (!node) return; + node.focus(); + }, [focusUrlNonce]); + + const submit = (event?: FormEvent | KeyboardEvent) => { + event?.preventDefault(); + const next = draft.trim(); + if (next.length === 0) return; + onSubmit(next); + inputRef.current?.blur(); + }; + + return ( +
+
+
+ + + } + > + + + Back + + + + } + > + + + Forward + + + + } + > + + + {loading ? "Loading…" : "Refresh"} + +
+ + + + setDraft(event.target.value)} + onFocus={() => { + setDraft(url); + setInputFocused(true); + queueMicrotask(() => inputRef.current?.select()); + }} + onBlur={() => { + setDraft(url); + setInputFocused(false); + }} + onKeyDown={(event) => { + if (event.key === "Enter") submit(event); + if (event.key === "Escape") { + event.preventDefault(); + setDraft(url); + inputRef.current?.blur(); + } + }} + placeholder="Search or enter URL" + spellCheck={false} + disabled={inputDisabled} + data-preview-url-input + size="sm" + /> + } + /> + {!inputFocused && displayUrl ? {url} : null} + + {onOpenInBrowser && !inputFocused ? ( + + + + } + > + + + Open in system browser + + + ) : null} + + + {onPickElement ? ( + + + } + > + + + + {pickDisabled && pickDisabledReason + ? pickDisabledReason + : pickActive + ? "Cancel annotation (Esc)" + : "Annotate elements, regions, and drawings"} + + + ) : null} + {onCapture ? ( + + onCapture(event.shiftKey)} + aria-label={recording ? "Stop recording" : "Capture screenshot"} + type="button" + className="relative" + disabled={captureDisabled} + /> + } + > + + {recording ? ( + + ) : null} + + + {recording ? "Stop recording" : "Screenshot · Shift-click to record"} + + + ) : null} + {trailingActions} +
+ {loadProgress > 0 ? ( +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx new file mode 100644 index 00000000000..12126c66408 --- /dev/null +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -0,0 +1,65 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { Globe, RadioTower } from "lucide-react"; + +import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from "~/components/ui/empty"; + +import { PreviewLocalServerCard } from "./PreviewLocalServerCard"; +import { useDiscoveredLocalServers } from "./useDiscoveredLocalServers"; + +interface Props { + environmentId: EnvironmentId; + configuredUrls?: ReadonlyArray | undefined; + recentlySeenUrls?: ReadonlyArray | undefined; + onOpenUrl: (url: string) => void; +} + +export function PreviewEmptyState({ + environmentId, + configuredUrls, + recentlySeenUrls, + onOpenUrl, +}: Props) { + const servers = useDiscoveredLocalServers({ + environmentId, + configuredUrls, + recentlySeenUrls, + }); + + if (servers.length === 0) { + return ( + + + + + No preview yet + + Type a URL above, or run a dev script. Listening localhost ports will show up here + automatically. + + + ); + } + + return ( +
+
+
+ +

Local servers

+
+
+ {servers.map((server) => ( + onOpenUrl(server.url)} + /> + ))} +
+

+ Select a listening port to open it in this browser tab. +

+
+
+ ); +} diff --git a/apps/web/src/components/preview/PreviewLocalServerCard.tsx b/apps/web/src/components/preview/PreviewLocalServerCard.tsx new file mode 100644 index 00000000000..54a020cbf65 --- /dev/null +++ b/apps/web/src/components/preview/PreviewLocalServerCard.tsx @@ -0,0 +1,52 @@ +import { BrowserMockup } from "./BrowserMockup"; +import type { PreviewableServer } from "./useDiscoveredLocalServers"; + +interface Props { + server: PreviewableServer; + onOpen: () => void; +} + +export function PreviewLocalServerCard({ server, onOpen }: Props) { + const subtitle = describeServer(server); + return ( + + ); +} + +function describeServer(server: PreviewableServer): string { + if (server.processName) return server.processName; + if (server.listening) return "Listening"; + if (server.source === "configured") return "Configured"; + return "Recently seen"; +} + +function PulsingDot() { + return ( + + + + + ); +} + +function DimDot() { + return ( + + ); +} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx new file mode 100644 index 00000000000..f11ff4d2d30 --- /dev/null +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; + +import { Button } from "~/components/ui/button"; +import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +import { previewBridge } from "./previewBridge"; + +interface Props { + /** Active preview tab id. Tab-targeting actions are disabled without it. */ + tabId: string | null; + /** + * True only after the desktop bridge has registered a `webContentsId` for + * the active tab. Tab-targeting actions throw on the desktop side until + * then; we disable those items so the menu doesn't fire silent no-ops. + */ + hasWebContents: boolean; + /** Current zoom factor as a number (1.0 = 100%). */ + zoomFactor: number; +} + +/** + * Three-dot menu in the chrome row. Wires Hard reload, DevTools, zoom + * controls, and storage-clearing actions. Only mounted by `PreviewView` + * when the desktop bridge is present, so we can call it unconditionally. + */ +export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor }: Props) { + if (!previewBridge) return null; + const bridge = previewBridge; + const tabDisabled = !tabId || !hasWebContents; + const callTab = (op: (tabId: string) => Promise) => () => { + if (!tabId) return; + void op(tabId).catch(() => undefined); + }; + + const zoomLabel = `${Math.round(zoomFactor * 100)}%`; + return ( + + + + } + /> + } + > + + + More + + + + Hard reload + + + Open DevTools + + {/* + Zoom row: label + inline control cluster. `closeOnClick=false` + keeps the menu open while the user clicks the +/− buttons. + */} + event.preventDefault()} + className="justify-between" + disabled={tabDisabled} + > + Zoom + + + + {zoomLabel} + + + + + + + void bridge.clearCookies().catch(() => undefined)}> + Clear cookies + + void bridge.clearCache().catch(() => undefined)}> + Clear cache + + + + ); +} diff --git a/apps/web/src/components/preview/PreviewPanel.tsx b/apps/web/src/components/preview/PreviewPanel.tsx new file mode 100644 index 00000000000..92db1c2cd9b --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanel.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ScopedThreadRef } from "@t3tools/contracts"; + +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; + +import { PreviewPanelShell, type PreviewPanelMode } from "./PreviewPanelShell"; +import { PreviewView } from "./PreviewView"; + +interface Props { + mode: PreviewPanelMode; + threadRef: ScopedThreadRef; + tabId?: string | null; + configuredUrls?: ReadonlyArray | undefined; + visible: boolean; +} + +export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }: Props) { + if (!isPreviewSupportedInRuntime()) { + return ( + +
+

+ Preview is only available in the T3 Code desktop app. +

+
+
+ ); + } + + return ( + + + + ); +} diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx new file mode 100644 index 00000000000..7243a4df42a --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -0,0 +1,77 @@ +import { type ReactNode, useEffect, useState } from "react"; + +import { isElectron } from "~/env"; +import { useResizableWidth } from "~/hooks/useResizableWidth"; +import { cn } from "~/lib/utils"; + +import { RightPanelResizeHandle } from "./RightPanelResizeHandle"; + +export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; + +const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; +const PREVIEW_PANEL_MIN_WIDTH = 360; +/** Hard ceiling so a wide monitor can't yield a panel that swallows the chat. */ +const PREVIEW_PANEL_MAX_WIDTH_PX = 1400; +/** Fraction of the viewport allowed; the panel is min(this · vw, MAX_PX). */ +const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; +const PREVIEW_PANEL_DEFAULT_WIDTH = 540; + +/** + * Shell for the preview panel. In inline mode the panel is user-resizable + * via a drag handle on the left edge; width persists per browser. In + * sheet/sidebar modes the parent owns the size. + */ +export function PreviewPanelShell(props: { mode: PreviewPanelMode; children: ReactNode }) { + const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; + const isInline = props.mode === "inline"; + const maxWidth = useViewportClampedMaxWidth(); + const { width, handlers } = useResizableWidth({ + storageKey: PREVIEW_PANEL_WIDTH_STORAGE_KEY, + defaultWidth: PREVIEW_PANEL_DEFAULT_WIDTH, + minWidth: PREVIEW_PANEL_MIN_WIDTH, + maxWidth, + edge: "left", + }); + + return ( +
+ {isInline ? : null} + {useDragRegion ?
: null} + {props.children} +
+ ); +} + +/** + * Track viewport width to derive a sensible upper bound for the panel. + * Resize-aware so dragging the OS window narrower re-clamps the stored + * width on the next render (the hook's clamp picks this up automatically). + */ +function useViewportClampedMaxWidth(): number { + const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); + useEffect(() => { + if (typeof window === "undefined") return; + let frame = 0; + const onResize = () => { + // Coalesce rapid resize events into one rAF tick. + if (frame !== 0) return; + frame = window.requestAnimationFrame(() => { + frame = 0; + setVw(window.innerWidth); + }); + }; + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("resize", onResize); + if (frame !== 0) window.cancelAnimationFrame(frame); + }; + }, []); + return Math.min(PREVIEW_PANEL_MAX_WIDTH_PX, Math.floor(vw * PREVIEW_PANEL_MAX_WIDTH_FRACTION)); +} diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx new file mode 100644 index 00000000000..c6ada2cb491 --- /dev/null +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; + +import { Button } from "~/components/ui/button"; + +import { describePreviewError } from "./errorCodeMessages"; + +interface Props { + url: string; + /** Chromium net error code, e.g. -105. */ + code: number; + /** Stringified Chromium error, e.g. "ERR_NAME_NOT_RESOLVED". */ + description: string; + onReload: () => void; +} + +/** Theme-aware tailwind port of Chromium's "This site can't be reached" page. */ +export function PreviewUnreachable({ url, code, description, onReload }: Props) { + const [showDetails, setShowDetails] = useState(false); + const host = safeHost(url) ?? url; + const friendly = describePreviewError(code, description); + const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; + + return ( +
+
+ +

+ This site can’t be reached +

+

+ {host}: {friendly}. +

+ + {showDetails ? ( +
+

Try:

+
    +
  • Checking your connection
  • +
  • Confirming the dev server is running
  • +
  • Checking the proxy and the firewall
  • +
+
+ ) : null} + +
+ {errorLabel} +
+ +
+ +
+ +
+
+
+ ); +} + +function ErrorIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function safeHost(url: string): string | null { + try { + return new URL(url).host; + } catch { + return null; + } +} diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx new file mode 100644 index 00000000000..3fc965f3423 --- /dev/null +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -0,0 +1,585 @@ +"use client"; + +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { type ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useComposerDraftStore } from "~/composerDraftStore"; +import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; +import { ensureLocalApi } from "~/localApi"; +import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; +import { usePreviewActions } from "~/state/preview"; + +import { previewBridge } from "./previewBridge"; +import { subscribePreviewAction } from "./previewActionBus"; +import { openPreviewSession } from "./openPreviewSession"; +import { PreviewChromeRow } from "./PreviewChromeRow"; +import { formatPreviewUrl } from "./previewUrlPresentation"; +import { PreviewEmptyState } from "./PreviewEmptyState"; +import { PreviewMoreMenu } from "./PreviewMoreMenu"; +import { PreviewUnreachable } from "./PreviewUnreachable"; +import { revealInFileExplorerLabel } from "./fileExplorerLabel"; +import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; +import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; +import { useLoadingProgress } from "./useLoadingProgress"; +import { usePreviewSession } from "./usePreviewSession"; +import { ZoomIndicator } from "./ZoomIndicator"; +import { AgentBrowserCursor } from "./AgentBrowserCursor"; +import { + startBrowserRecording, + stopBrowserRecording, + useBrowserRecordingStore, +} from "~/browser/browserRecording"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; + +interface Props { + threadRef: ScopedThreadRef; + tabId?: string | null; + configuredUrls?: ReadonlyArray | undefined; + visible: boolean; +} + +const localApi = typeof window === "undefined" ? null : ensureLocalApi(); + +/** + * Single-tab preview surface: chrome row on top, one webview below, empty + * state when no session exists for the thread. + */ +export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { + const [focusUrlNonce, setFocusUrlNonce] = useState(undefined); + const [pickActive, setPickActive] = useState(false); + const activeRecordingTabId = useBrowserRecordingStore((state) => state.activeTabId); + const pickActiveRef = useRef(false); + const isMountedRef = useRef(true); + const previewState = usePreviewStateStore((state) => + selectThreadPreviewState(state.byThreadKey, threadRef), + ); + const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); + const rememberUrl = usePreviewStateStore((state) => state.rememberUrl); + const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); + const addImage = useComposerDraftStore((store) => store.addImage); + const environment = useEnvironment(threadRef.environmentId); + const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); + const { open } = usePreviewActions(); + + usePreviewSession(threadRef); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const tabId = requestedTabId ?? previewState.activeTabId; + const snapshot = tabId ? (previewState.sessions[tabId] ?? null) : null; + const desktopOverlay = tabId ? (previewState.desktopByTabId[tabId] ?? null) : null; + const navStatus = snapshot?.navStatus ?? { _tag: "Idle" as const }; + const url = navStatus._tag === "Idle" ? "" : navStatus.url; + const loading = desktopOverlay?.loading ?? navStatus._tag === "Loading"; + const canGoBack = desktopOverlay?.canGoBack ?? snapshot?.canGoBack ?? false; + const canGoForward = desktopOverlay?.canGoForward ?? snapshot?.canGoForward ?? false; + const refreshDisabled = navStatus._tag === "Idle"; + const isUnreachable = navStatus._tag === "LoadFailed"; + const showEmptyState = shouldShowPreviewEmptyState(snapshot); + const controller = desktopOverlay?.controller ?? "none"; + const loadProgress = useLoadingProgress(loading); + const displayUrl = + url && environment && environmentHttpBaseUrl + ? (formatPreviewUrl({ + url, + environmentLabel: environment.label, + environmentHttpBaseUrl, + }) ?? undefined) + : undefined; + + const handleSubmitUrl = useCallback( + async (next: string) => { + try { + const resolvedUrl = resolveDiscoveredServerUrl(threadRef.environmentId, next); + if (tabId && previewBridge) { + // Drive the webview imperatively; `usePreviewBridge` mirrors the + // resolved URL back to the server so other clients stay in sync. + await previewBridge.navigate(tabId, resolvedUrl); + rememberUrl(threadRef, resolvedUrl); + } else { + await openPreviewSession({ + openPreview: open, + threadRef, + url: resolvedUrl, + applyServerSnapshot, + rememberUrl, + }); + } + } catch { + // Server-side `failed` event renders the unreachable view. + } + }, + [applyServerSnapshot, open, rememberUrl, tabId, threadRef], + ); + + const handleRefresh = useCallback(() => { + if (previewBridge && tabId) void previewBridge.refresh(tabId); + }, [tabId]); + + const handleZoomIn = useCallback(() => { + if (previewBridge && tabId) void previewBridge.zoomIn(tabId); + }, [tabId]); + + const handleZoomOut = useCallback(() => { + if (previewBridge && tabId) void previewBridge.zoomOut(tabId); + }, [tabId]); + + const handleResetZoom = useCallback(() => { + if (previewBridge && tabId) void previewBridge.resetZoom(tabId); + }, [tabId]); + + const handleBack = useCallback(() => { + if (previewBridge && tabId) void previewBridge.goBack(tabId); + }, [tabId]); + + const handleForward = useCallback(() => { + if (previewBridge && tabId) void previewBridge.goForward(tabId); + }, [tabId]); + + const handleOpenInBrowser = useCallback(() => { + if (!localApi || !url) return; + void localApi.shell.openExternal(url).catch(() => undefined); + }, [url]); + + const handleCapture = useCallback( + (record: boolean) => { + if (!previewBridge || !tabId) return; + const bridge = previewBridge; + const recordingThisTab = activeRecordingTabId === tabId; + if (recordingThisTab) { + void stopBrowserRecording(tabId).then( + (artifact) => { + if (!artifact) return; + let pathCopied = false; + let toastId: ReturnType; + + const copyPath = () => { + if (!navigator.clipboard?.writeText) { + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Unable to copy recording path", + description: "Clipboard API unavailable.", + actionProps: revealAction, + }), + ); + return; + } + + void navigator.clipboard.writeText(artifact.path).then( + () => { + pathCopied = true; + updateRecordingToast(); + window.setTimeout(() => { + pathCopied = false; + updateRecordingToast(); + }, 2_000); + }, + (error) => { + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Unable to copy recording path", + description: error instanceof Error ? error.message : "An error occurred.", + actionProps: revealAction, + }), + ); + }, + ); + }; + + const revealAction = { + children: revealInFileExplorerLabel(navigator.platform), + onClick: () => void bridge.revealArtifact(artifact.path), + }; + const updateRecordingToast = () => { + toastManager.update( + toastId, + stackedThreadToast({ + type: "success", + title: "Recording saved", + actionProps: revealAction, + data: { + secondaryActionProps: { + children: pathCopied ? "Copied!" : "Copy path", + disabled: pathCopied, + onClick: copyPath, + }, + secondaryActionVariant: "outline", + }, + }), + ); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: "success", + title: "Recording saved", + actionProps: revealAction, + data: { + secondaryActionProps: { + children: "Copy path", + onClick: copyPath, + }, + secondaryActionVariant: "outline", + }, + }), + ); + }, + (error) => { + toastManager.add({ + type: "error", + title: "Unable to stop recording", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + return; + } + if (record) { + if (activeRecordingTabId !== null) { + toastManager.add({ + type: "warning", + title: "Another preview is recording", + description: "Stop the active recording before starting a new one.", + }); + return; + } + void startBrowserRecording(tabId).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to start recording", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + return; + } + void bridge.captureScreenshot(tabId).then( + (artifact) => { + const revealAction = { + children: revealInFileExplorerLabel(navigator.platform), + onClick: () => void bridge.revealArtifact(artifact.path), + }; + let pathCopied = false; + let imageCopied = false; + let toastId: ReturnType; + + const updateScreenshotToast = ( + type: "success" | "error" = "success", + title = "Screenshot saved", + description?: string, + ) => { + toastManager.update( + toastId, + stackedThreadToast({ + type, + title, + description, + actionProps: { + children: imageCopied ? "Copied!" : "Copy image", + disabled: imageCopied, + onClick: copyImage, + }, + data: { + additionalActions: [ + { + id: "copy-path", + props: { + children: pathCopied ? "Copied!" : "Copy path", + disabled: pathCopied, + onClick: copyPath, + }, + }, + ], + secondaryActionProps: { + ...revealAction, + }, + secondaryActionVariant: "outline", + }, + }), + ); + }; + + const copyPath = () => { + if (!navigator.clipboard?.writeText) { + updateScreenshotToast( + "error", + "Unable to copy screenshot path", + "Clipboard API unavailable.", + ); + return; + } + + void navigator.clipboard.writeText(artifact.path).then( + () => { + pathCopied = true; + updateScreenshotToast(); + window.setTimeout(() => { + pathCopied = false; + updateScreenshotToast(); + }, 2_000); + }, + (error) => { + updateScreenshotToast( + "error", + "Unable to copy screenshot path", + error instanceof Error ? error.message : "An error occurred.", + ); + }, + ); + }; + + const copyImage = () => { + void bridge.copyArtifactToClipboard(artifact.path).then( + () => { + imageCopied = true; + updateScreenshotToast(); + window.setTimeout(() => { + imageCopied = false; + updateScreenshotToast(); + }, 2_000); + }, + (error) => { + updateScreenshotToast( + "error", + "Unable to copy screenshot", + error instanceof Error ? error.message : "An error occurred.", + ); + }, + ); + }; + + toastId = toastManager.add( + stackedThreadToast({ + type: "success", + title: "Screenshot saved", + actionProps: { + children: "Copy image", + onClick: copyImage, + }, + data: { + additionalActions: [ + { + id: "copy-path", + props: { + children: "Copy path", + onClick: copyPath, + }, + }, + ], + secondaryActionProps: { + ...revealAction, + }, + secondaryActionVariant: "outline", + }, + }), + ); + }, + (error) => { + toastManager.add({ + type: "error", + title: "Unable to capture screenshot", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }, + ); + }, + [activeRecordingTabId, tabId], + ); + + const handlePickElement = useCallback(() => { + if (!previewBridge || !tabId) return; + if (pickActiveRef.current) { + void previewBridge.cancelPickElement(tabId).catch(() => undefined); + return; + } + // Snapshot whatever the user was focused on (typically the chat + // composer textarea or the chrome-row pick button) BEFORE main steals + // focus into the guest webContents. We restore it when the pick + // resolves so the user's typing context isn't lost — otherwise after + // every pick they'd have to click back into the textarea. + const previouslyFocused = + typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null; + pickActiveRef.current = true; + setPickActive(true); + void (async () => { + try { + const annotation = await previewBridge.pickElement(tabId); + if (!annotation) return; + addPreviewAnnotation(threadRef, annotation); + const screenshotFile = await previewAnnotationScreenshotFile(annotation); + if (screenshotFile && annotation.screenshot) { + addImage(threadRef, { + type: "image", + id: annotation.id, + name: screenshotFile.name, + mimeType: screenshotFile.type, + sizeBytes: screenshotFile.size, + previewUrl: annotation.screenshot.dataUrl, + file: screenshotFile, + }); + } + } catch { + // Picker failed (e.g. webview navigated). Treat as silent cancel. + } finally { + pickActiveRef.current = false; + // Avoid `setState on unmounted component` if the panel/thread closed + // while the pick was in flight. + if (isMountedRef.current) setPickActive(false); + // Best-effort: restore focus to whatever the user had before the + // pick stole it into the guest webContents. Skip if the previously- + // focused element was unmounted or is no longer focusable. + if ( + previouslyFocused && + previouslyFocused.isConnected && + typeof previouslyFocused.focus === "function" + ) { + try { + previouslyFocused.focus({ preventScroll: true }); + } catch { + // Some elements throw on .focus() (detached iframes, etc.). + } + } + } + })(); + }, [addImage, addPreviewAnnotation, tabId, threadRef]); + + // If the active tab changes mid-pick (close, thread switch, hot restart), + // tell main to tear down the in-flight session AND reset our local toggle + // state so the button doesn't get stuck pressed against a stale tab id. + useEffect(() => { + return () => { + if (!pickActiveRef.current) return; + pickActiveRef.current = false; + if (previewBridge && tabId) { + void previewBridge.cancelPickElement(tabId).catch(() => undefined); + } + if (isMountedRef.current) setPickActive(false); + }; + }, [tabId]); + + // Subscribe only while visible; `toggle-panel` is owned by ChatView's + // URL-aware handler regardless of whether the panel is currently mounted. + useEffect(() => { + if (!visible) return; + return subscribePreviewAction((action) => { + switch (action) { + case "refresh": + handleRefresh(); + return; + case "focus-url": + setFocusUrlNonce((value) => (value ?? 0) + 1); + return; + case "zoom-in": + handleZoomIn(); + return; + case "zoom-out": + handleZoomOut(); + return; + case "reset-zoom": + handleResetZoom(); + return; + case "toggle-panel": + return; + } + }); + }, [handleRefresh, handleResetZoom, handleZoomIn, handleZoomOut, visible]); + + return ( +
+ void handleSubmitUrl(next)} + onOpenInBrowser={tabId ? handleOpenInBrowser : undefined} + onCapture={previewBridge && tabId ? handleCapture : undefined} + captureDisabled={!desktopOverlay || isUnreachable} + recording={tabId !== null && activeRecordingTabId === tabId} + onPickElement={previewBridge && tabId ? handlePickElement : undefined} + pickActive={pickActive} + // Disable when there's no tab (nothing to pick on) OR the page + // failed to load (a React overlay covers the webview, so the + // user wouldn't be able to actually click anything underneath). + pickDisabled={!tabId || isUnreachable} + pickDisabledReason={ + isUnreachable ? "Page didn't load — pick unavailable until the page renders" : undefined + } + trailingActions={ + previewBridge ? ( + + ) : null + } + /> + +
+ {tabId && snapshot && !showEmptyState ? ( + + ) : null} + {showEmptyState ? ( + void handleSubmitUrl(next)} + /> + ) : null} + {snapshot && desktopOverlay ? ( + + ) : null} + {tabId && desktopOverlay && !showEmptyState && !isUnreachable ? ( + + ) : null} + {controller !== "none" ? ( +
+ {controller === "agent" ? "Agent controlling browser" : "Human control"} +
+ ) : null} + {navStatus._tag === "LoadFailed" ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/preview/RightPanelResizeHandle.tsx b/apps/web/src/components/preview/RightPanelResizeHandle.tsx new file mode 100644 index 00000000000..5091c8b5915 --- /dev/null +++ b/apps/web/src/components/preview/RightPanelResizeHandle.tsx @@ -0,0 +1,34 @@ +import type { ResizableWidthHandlers } from "~/hooks/useResizableWidth"; +import { cn } from "~/lib/utils"; + +interface Props { + handlers: ResizableWidthHandlers; + className?: string; +} + +/** + * Hit target for resizing a right-anchored panel via its left edge. + * + * - Sits on top of the panel's border with a 4px overlap on each side so the + * user can grab a few pixels off the edge without aiming. + * - Visual indicator is a 1px line that lights up on hover/active to mirror + * VS Code / Cursor. + */ +export function RightPanelResizeHandle({ handlers, className }: Props) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/preview/ZoomIndicator.tsx b/apps/web/src/components/preview/ZoomIndicator.tsx new file mode 100644 index 00000000000..0ac536668ac --- /dev/null +++ b/apps/web/src/components/preview/ZoomIndicator.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +const HIDE_AFTER_MS = 1500; +const ZOOM_EPSILON = 0.001; + +interface Props { + /** Current zoom factor (1.0 = 100%); changes drive the transient indicator. */ + zoomFactor: number; +} + +/** + * Floating "X%" pill that surfaces in the top-right of the webview area + * whenever the zoom factor changes, then fades out after a short pause. + * + * Suppressed for the first render's value so we don't flash 100% on mount. + */ +export function ZoomIndicator({ zoomFactor }: Props) { + const [visible, setVisible] = useState(false); + const lastFactorRef = useRef(zoomFactor); + const timerRef = useRef(null); + + useEffect(() => { + if (Math.abs(lastFactorRef.current - zoomFactor) < ZOOM_EPSILON) return; + lastFactorRef.current = zoomFactor; + setVisible(true); + if (timerRef.current !== null) window.clearTimeout(timerRef.current); + timerRef.current = window.setTimeout(() => { + setVisible(false); + timerRef.current = null; + }, HIDE_AFTER_MS); + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [zoomFactor]); + + const percent = `${Math.round(zoomFactor * 100)}%`; + + return ( +
+ {percent} +
+ ); +} diff --git a/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts b/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts new file mode 100644 index 00000000000..fa01bc138af --- /dev/null +++ b/apps/web/src/components/preview/agentBrowserCursorLogic.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { agentBrowserCursorOpacity } from "./agentBrowserCursorLogic"; + +describe("agentBrowserCursorOpacity", () => { + it("keeps active movement fully visible", () => { + expect(agentBrowserCursorOpacity(true, "agent")).toBe(1); + expect(agentBrowserCursorOpacity(true, "human")).toBe(1); + }); + + it("settles to a visible idle state", () => { + expect(agentBrowserCursorOpacity(false, "none")).toBe(0.35); + expect(agentBrowserCursorOpacity(false, "agent")).toBe(0.35); + }); + + it("dims further while the human controls the page", () => { + expect(agentBrowserCursorOpacity(false, "human")).toBe(0.18); + }); +}); diff --git a/apps/web/src/components/preview/agentBrowserCursorLogic.ts b/apps/web/src/components/preview/agentBrowserCursorLogic.ts new file mode 100644 index 00000000000..0dfdc64aa6b --- /dev/null +++ b/apps/web/src/components/preview/agentBrowserCursorLogic.ts @@ -0,0 +1,6 @@ +export type BrowserController = "human" | "agent" | "none"; + +export function agentBrowserCursorOpacity(active: boolean, controller: BrowserController): number { + if (active) return 1; + return controller === "human" ? 0.18 : 0.35; +} diff --git a/apps/web/src/components/preview/errorCodeMessages.ts b/apps/web/src/components/preview/errorCodeMessages.ts new file mode 100644 index 00000000000..78ee928800b --- /dev/null +++ b/apps/web/src/components/preview/errorCodeMessages.ts @@ -0,0 +1,12 @@ +import { PREVIEW_ERROR_CODE_MESSAGES } from "./previewConstants"; + +/** + * Resolve a friendly description for a Chromium / network error. Falls back + * to the description string passed in when the code isn't in our table. + */ +export function describePreviewError(code: number, description: string): string { + const friendly = PREVIEW_ERROR_CODE_MESSAGES[description]; + if (friendly) return friendly; + if (description.length > 0) return description; + return `Network error (${code})`; +} diff --git a/apps/web/src/components/preview/fileExplorerLabel.test.ts b/apps/web/src/components/preview/fileExplorerLabel.test.ts new file mode 100644 index 00000000000..0b39a8d9ef9 --- /dev/null +++ b/apps/web/src/components/preview/fileExplorerLabel.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { revealInFileExplorerLabel } from "./fileExplorerLabel"; + +describe("revealInFileExplorerLabel", () => { + it.each([ + ["MacIntel", "Reveal in Finder"], + ["Win32", "Reveal in File Explorer"], + ["Linux x86_64", "Reveal in Files"], + ])("maps %s to %s", (platform, expected) => { + expect(revealInFileExplorerLabel(platform)).toBe(expected); + }); +}); diff --git a/apps/web/src/components/preview/fileExplorerLabel.ts b/apps/web/src/components/preview/fileExplorerLabel.ts new file mode 100644 index 00000000000..fd0785dfadf --- /dev/null +++ b/apps/web/src/components/preview/fileExplorerLabel.ts @@ -0,0 +1,6 @@ +export function revealInFileExplorerLabel(platform: string): string { + const normalized = platform.toLowerCase(); + if (normalized.includes("mac")) return "Reveal in Finder"; + if (normalized.includes("win")) return "Reveal in File Explorer"; + return "Reveal in Files"; +} diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts new file mode 100644 index 00000000000..e8c5c194668 --- /dev/null +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -0,0 +1,24 @@ +import type { DiscoveredLocalServer, ScopedThreadRef } from "@t3tools/contracts"; + +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; +import { openPreviewSession } from "./openPreviewSession"; + +export async function openDiscoveredPort(input: { + readonly threadRef: ScopedThreadRef; + readonly port: DiscoveredLocalServer; + readonly openPreview: OpenPreviewMutation; +}): Promise { + const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); + const previewState = usePreviewStateStore.getState(); + const snapshot = await openPreviewSession({ + openPreview: input.openPreview, + threadRef: input.threadRef, + url: resolvedUrl, + applyServerSnapshot: previewState.applyServerSnapshot, + rememberUrl: previewState.rememberUrl, + }); + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); +} diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts new file mode 100644 index 00000000000..fee066fd812 --- /dev/null +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -0,0 +1,42 @@ +import type { PreviewOpenInput, PreviewSessionSnapshot, ScopedThreadRef } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openPreviewSession } from "./openPreviewSession"; + +const threadRef = { + environmentId: "local" as ScopedThreadRef["environmentId"], + threadId: "thread-1" as ScopedThreadRef["threadId"], +}; + +const snapshot: PreviewSessionSnapshot = { + threadId: threadRef.threadId, + tabId: "tab-1", + navStatus: { + _tag: "Loading", + url: "https://t3.chat/", + title: "", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-11T23:00:00.000Z", +}; + +describe("openPreviewSession", () => { + it("applies the RPC response without waiting for a preview event", async () => { + const open = vi.fn(async (_input: PreviewOpenInput) => snapshot); + const applyServerSnapshot = vi.fn(); + const rememberUrl = vi.fn(); + + await openPreviewSession({ + openPreview: ({ input }) => open(input), + threadRef, + url: "t3.chat", + applyServerSnapshot, + rememberUrl, + }); + + expect(open).toHaveBeenCalledWith({ threadId: "thread-1", url: "t3.chat" }); + expect(applyServerSnapshot).toHaveBeenCalledWith(threadRef, snapshot); + expect(rememberUrl).toHaveBeenCalledWith(threadRef, "https://t3.chat/"); + }); +}); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts new file mode 100644 index 00000000000..13ddb3165a1 --- /dev/null +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -0,0 +1,37 @@ +import type { + EnvironmentId, + PreviewOpenInput, + PreviewSessionSnapshot, + ScopedThreadRef, +} from "@t3tools/contracts"; + +import type { PreviewStateStoreState } from "~/previewStateStore"; + +interface OpenPreviewSessionInput { + openPreview: (input: { + readonly environmentId: EnvironmentId; + readonly input: PreviewOpenInput; + }) => Promise; + threadRef: ScopedThreadRef; + url: string; + applyServerSnapshot: PreviewStateStoreState["applyServerSnapshot"]; + rememberUrl: PreviewStateStoreState["rememberUrl"]; +} + +export async function openPreviewSession( + input: OpenPreviewSessionInput, +): Promise { + const snapshot = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { + threadId: input.threadRef.threadId, + url: input.url, + }, + }); + input.applyServerSnapshot(input.threadRef, snapshot); + input.rememberUrl( + input.threadRef, + snapshot.navStatus._tag === "Idle" ? input.url : snapshot.navStatus.url, + ); + return snapshot; +} diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts new file mode 100644 index 00000000000..67ff578221c --- /dev/null +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -0,0 +1,61 @@ +import type { LocalApi, ScopedThreadRef } from "@t3tools/contracts"; +import { isPreviewableUrl } from "@t3tools/shared/preview"; + +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import { isPreviewSupportedInRuntime, usePreviewStateStore } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +interface OpenTerminalLinkInPreviewInput { + readonly url: string; + readonly position: { x: number; y: number }; + readonly threadRef: ScopedThreadRef; + readonly openPreview: OpenPreviewMutation; + readonly localApi: LocalApi; + readonly fallbackToBrowser: () => void; +} + +export async function openTerminalLinkInPreview( + input: OpenTerminalLinkInPreviewInput, +): Promise { + const supportsPreview = + isPreviewableUrl(input.url) && + isPreviewSupportedInRuntime() && + input.threadRef.threadId.length > 0; + + if (!supportsPreview) { + input.fallbackToBrowser(); + return; + } + + let choice: "open-in-preview" | "open-in-browser" | null; + try { + choice = await input.localApi.contextMenu.show( + [ + { id: "open-in-preview", label: "Open in preview" }, + { id: "open-in-browser", label: "Open in browser" }, + ], + input.position, + ); + } catch { + input.fallbackToBrowser(); + return; + } + + if (choice === "open-in-preview") { + try { + const snapshot = await input.openPreview({ + environmentId: input.threadRef.environmentId, + input: { threadId: input.threadRef.threadId, url: input.url }, + }); + usePreviewStateStore.getState().applyServerSnapshot(input.threadRef, snapshot); + useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + } catch { + input.fallbackToBrowser(); + } + return; + } + + if (choice === "open-in-browser") { + input.fallbackToBrowser(); + } +} diff --git a/apps/web/src/components/preview/previewActionBus.ts b/apps/web/src/components/preview/previewActionBus.ts new file mode 100644 index 00000000000..23efdf487c6 --- /dev/null +++ b/apps/web/src/components/preview/previewActionBus.ts @@ -0,0 +1,31 @@ +"use client"; + +/** + * Typed window-event bus for preview-panel actions. Lets the global + * keybinding handler in `routes/_chat.tsx` reach `ChatView`'s URL-aware + * arbitration without prop drilling or shared refs. + */ +export type PreviewAction = + | "toggle-panel" + | "refresh" + | "focus-url" + | "zoom-in" + | "zoom-out" + | "reset-zoom"; + +const EVENT_NAME = "t3code:preview-action"; + +export function dispatchPreviewAction(action: PreviewAction): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: action })); +} + +export function subscribePreviewAction(listener: (action: PreviewAction) => void): () => void { + if (typeof window === "undefined") return () => {}; + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (typeof detail === "string") listener(detail); + }; + window.addEventListener(EVENT_NAME, handler); + return () => window.removeEventListener(EVENT_NAME, handler); +} diff --git a/apps/web/src/components/preview/previewBridge.ts b/apps/web/src/components/preview/previewBridge.ts new file mode 100644 index 00000000000..90e9bf7241b --- /dev/null +++ b/apps/web/src/components/preview/previewBridge.ts @@ -0,0 +1,9 @@ +/** + * Module-level handle to the desktop preview bridge. + * + * Resolved once at import time so React hooks don't pay for repeated + * `window.desktopBridge?.preview` lookups on every render. `null` on the web + * build where there's no Electron host. + */ +export const previewBridge = + typeof window === "undefined" ? null : (window.desktopBridge?.preview ?? null); diff --git a/apps/web/src/components/preview/previewConstants.ts b/apps/web/src/components/preview/previewConstants.ts new file mode 100644 index 00000000000..d174577a584 --- /dev/null +++ b/apps/web/src/components/preview/previewConstants.ts @@ -0,0 +1,21 @@ +/** Cap for the per-thread "recently seen" URL list shown in the empty state. */ +export const PREVIEW_RECENT_URL_LIMIT = 10; + +/** + * Common Chromium error codes mapped to a short human label. Used by the + * unreachable view to drop the raw `ERR_*` code in favour of friendlier copy. + */ +export const PREVIEW_ERROR_CODE_MESSAGES: Readonly> = Object.freeze({ + ERR_NAME_NOT_RESOLVED: "DNS address could not be found", + ERR_NAME_RESOLUTION_FAILED: "DNS address could not be found", + ERR_CONNECTION_REFUSED: "Connection refused", + ERR_CONNECTION_RESET: "Connection was reset", + ERR_CONNECTION_CLOSED: "Connection was closed", + ERR_CONNECTION_TIMED_OUT: "Connection timed out", + ERR_INTERNET_DISCONNECTED: "No internet connection", + ERR_TIMED_OUT: "Connection timed out", + ERR_CERT_AUTHORITY_INVALID: "Certificate authority is not trusted", + ERR_CERT_COMMON_NAME_INVALID: "Certificate hostname mismatch", + ERR_CERT_DATE_INVALID: "Certificate is expired or not yet valid", + ERR_TOO_MANY_REDIRECTS: "Too many redirects", +}); diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.test.ts b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts new file mode 100644 index 00000000000..3759173d3cc --- /dev/null +++ b/apps/web/src/components/preview/previewEmptyStateLogic.test.ts @@ -0,0 +1,42 @@ +import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getConfiguredPreviewUrls, shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; + +const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId: "tab-1", + navStatus, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-06-12T20:00:00.000Z", +}); + +describe("shouldShowPreviewEmptyState", () => { + it("shows quick-open options for a new idle browser tab", () => { + expect(shouldShowPreviewEmptyState(snapshot({ _tag: "Idle" }))).toBe(true); + }); + + it("shows browser content once navigation starts", () => { + expect( + shouldShowPreviewEmptyState( + snapshot({ _tag: "Loading", url: "http://localhost:5173", title: "" }), + ), + ).toBe(false); + }); +}); + +describe("getConfiguredPreviewUrls", () => { + it("collects configured preview URLs from project scripts", () => { + const scripts = [ + { previewUrl: "http://localhost:5173" }, + {}, + { previewUrl: "http://localhost:3000" }, + ] as ProjectScript[]; + + expect(getConfiguredPreviewUrls(scripts)).toEqual([ + "http://localhost:5173", + "http://localhost:3000", + ]); + }); +}); diff --git a/apps/web/src/components/preview/previewEmptyStateLogic.ts b/apps/web/src/components/preview/previewEmptyStateLogic.ts new file mode 100644 index 00000000000..1ebd074032b --- /dev/null +++ b/apps/web/src/components/preview/previewEmptyStateLogic.ts @@ -0,0 +1,11 @@ +import type { PreviewSessionSnapshot, ProjectScript } from "@t3tools/contracts"; + +export function shouldShowPreviewEmptyState(snapshot: PreviewSessionSnapshot | null): boolean { + return snapshot === null || snapshot.navStatus._tag === "Idle"; +} + +export function getConfiguredPreviewUrls( + scripts: ReadonlyArray | undefined, +): ReadonlyArray { + return scripts?.flatMap((script) => (script.previewUrl ? [script.previewUrl] : [])) ?? []; +} diff --git a/apps/web/src/components/preview/previewSessionState.ts b/apps/web/src/components/preview/previewSessionState.ts new file mode 100644 index 00000000000..7f87ded4e77 --- /dev/null +++ b/apps/web/src/components/preview/previewSessionState.ts @@ -0,0 +1,41 @@ +import type { PreviewListResult, ScopedThreadRef } from "@t3tools/contracts"; + +import { readPreviewStateRevision } from "~/previewStateStore"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { previewEnvironment } from "~/state/preview"; +import { useEnvironmentQuery } from "~/state/query"; + +export interface PreviewSessionQueryState { + readonly data: { + readonly result: PreviewListResult; + readonly revision: number; + } | null; + readonly error: string | null; + readonly isPending: boolean; +} + +function previewSessionListAtom(threadRef: ScopedThreadRef) { + return previewEnvironment.list({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); +} + +export function refreshPreviewSessionState(threadRef: ScopedThreadRef): void { + appAtomRegistry.refresh(previewSessionListAtom(threadRef)); +} + +export function usePreviewSessionState(threadRef: ScopedThreadRef): PreviewSessionQueryState { + const query = useEnvironmentQuery(previewSessionListAtom(threadRef)); + return { + data: + query.data === null + ? null + : { + result: query.data, + revision: readPreviewStateRevision(threadRef), + }, + error: query.error, + isPending: query.isPending, + }; +} diff --git a/apps/web/src/components/preview/previewUrlPresentation.test.ts b/apps/web/src/components/preview/previewUrlPresentation.test.ts new file mode 100644 index 00000000000..c8c3a3245a2 --- /dev/null +++ b/apps/web/src/components/preview/previewUrlPresentation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatPreviewUrl } from "./previewUrlPresentation"; + +describe("formatPreviewUrl", () => { + it("formats signed asset URLs with the environment label and decoded filename", () => { + expect( + formatPreviewUrl({ + url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("Local environment · architecture brief.pdf"); + }); + + it("does not alias assets from another origin", () => { + expect( + formatPreviewUrl({ + url: "https://example.com/api/assets/token/report.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("example.com"); + }); + + it("formats regular preview URLs as their exact host", () => { + expect( + formatPreviewUrl({ + url: "http://127.0.0.1:5173/dashboard", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBe("127.0.0.1:5173"); + }); + + it("does not compact non-http URLs", () => { + expect( + formatPreviewUrl({ + url: "file:///tmp/report.pdf", + environmentLabel: "Local environment", + environmentHttpBaseUrl: "http://127.0.0.1:3773", + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/components/preview/previewUrlPresentation.ts b/apps/web/src/components/preview/previewUrlPresentation.ts new file mode 100644 index 00000000000..0ae3c1900aa --- /dev/null +++ b/apps/web/src/components/preview/previewUrlPresentation.ts @@ -0,0 +1,27 @@ +interface PreviewUrlPresentationInput { + readonly url: string; + readonly environmentLabel: string; + readonly environmentHttpBaseUrl: string; +} + +export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null { + try { + const url = new URL(input.url); + const environmentUrl = new URL(input.environmentHttpBaseUrl); + if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) { + const encodedFileName = url.pathname.split("/").at(-1); + if (!encodedFileName) { + return null; + } + const fileName = decodeURIComponent(encodedFileName); + if (!fileName || fileName === "." || fileName === "..") { + return null; + } + return `${input.environmentLabel} · ${fileName}`; + } + + return url.protocol === "http:" || url.protocol === "https:" ? url.host : null; + } catch { + return null; + } +} diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts new file mode 100644 index 00000000000..bb3b7cd6fa8 --- /dev/null +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.test.ts @@ -0,0 +1,117 @@ +import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; + +const scannerServer = (overrides: Partial): DiscoveredLocalServer => ({ + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "vite", + pid: 1234, + terminal: null, + ...overrides, +}); + +describe("mergeServers", () => { + it("returns scanner-only entries unchanged", () => { + const result = mergeServers({ + scanner: [scannerServer({})], + configuredUrls: [], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + host: "localhost", + port: 5173, + source: "scanner", + listening: true, + processName: "vite", + }); + }); + + it("enriches a configured entry with live process metadata when scanner sees it", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + port: 5173, + source: "configured", + listening: true, + processName: "node", + pid: 9999, + }); + }); + + it("keeps configured entries that the scanner doesn't see, with listening=false", () => { + const result = mergeServers({ + scanner: [], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + source: "configured", + listening: false, + }); + }); + + it("dedupes recently-seen URLs against scanner+configured entries", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 5173 })], + configuredUrls: [], + recentlySeenUrls: ["http://localhost:5173/", "http://localhost:8080/"], + }); + expect(result.map((s) => s.port)).toEqual([5173, 8080]); + expect(result.find((s) => s.port === 5173)?.source).toBe("scanner"); + expect(result.find((s) => s.port === 8080)?.source).toBe("recent"); + }); + + it("ignores non-loopback URLs in configured/recent inputs", () => { + const result = mergeServers({ + scanner: [], + configuredUrls: ["https://example.com", "ws://localhost:5173"], + recentlySeenUrls: ["https://api.example.com"], + }); + expect(result).toHaveLength(0); + }); + + it("sorts: configured before scanner before recent, then by port", () => { + const result = mergeServers({ + scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: ["http://localhost:9000/", "http://localhost:4321/"], + }); + expect(result.map((s) => `${s.source}:${s.port}`)).toEqual([ + "configured:5173", + "scanner:3000", + "scanner:8080", + "recent:4321", + "recent:9000", + ]); + }); + + it("dedupes by lowercased host", () => { + const result = mergeServers({ + scanner: [scannerServer({ host: "Localhost", port: 5173 })], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + expect(result).toHaveLength(1); + }); +}); + +describe("PreviewableServer interface", () => { + it("preserves listening flag through enrichment", () => { + const result = mergeServers({ + scanner: [scannerServer({})], + configuredUrls: ["http://localhost:5173"], + recentlySeenUrls: [], + }); + const merged: PreviewableServer | undefined = result[0]; + expect(merged?.listening).toBe(true); + }); +}); diff --git a/apps/web/src/components/preview/useDiscoveredLocalServers.ts b/apps/web/src/components/preview/useDiscoveredLocalServers.ts new file mode 100644 index 00000000000..118a56b9068 --- /dev/null +++ b/apps/web/src/components/preview/useDiscoveredLocalServers.ts @@ -0,0 +1,138 @@ +import type { DiscoveredLocalServer } from "@t3tools/contracts"; +import { isLoopbackHost } from "@t3tools/shared/preview"; +import { useMemo } from "react"; + +import type { EnvironmentId } from "@t3tools/contracts"; +import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; +import { useDiscoveredPorts } from "~/portDiscoveryState"; + +export interface PreviewableServer extends DiscoveredLocalServer { + source: "scanner" | "configured" | "recent"; + /** + * True when the port scanner currently sees this server listening. A + * `configured` entry can also be `listening` when the scan enriched it. + */ + listening: boolean; +} + +interface UseDiscoveredLocalServersInput { + environmentId: EnvironmentId; + configuredUrls?: ReadonlyArray | undefined; + recentlySeenUrls?: ReadonlyArray | undefined; +} + +/** + * Merge the environment-level port snapshot with configured / recently-seen + * URLs and return a stable sorted list. + */ +export function useDiscoveredLocalServers( + input: UseDiscoveredLocalServersInput, +): ReadonlyArray { + const scannerSnapshot = useDiscoveredPorts(input.environmentId); + + return useMemo( + () => + mergeServers({ + scanner: scannerSnapshot.map((server) => ({ + ...server, + url: resolveDiscoveredServerUrl(input.environmentId, server.url), + })), + configuredUrls: input.configuredUrls ?? [], + recentlySeenUrls: input.recentlySeenUrls ?? [], + }), + [input.environmentId, scannerSnapshot, input.configuredUrls, input.recentlySeenUrls], + ); +} + +export function mergeServers(input: { + scanner: ReadonlyArray; + configuredUrls: ReadonlyArray; + recentlySeenUrls: ReadonlyArray; +}): ReadonlyArray { + const seen = new Map(); + + for (const url of input.configuredUrls) { + const parsed = parseLocalUrl(url); + if (!parsed) continue; + const key = canonicalKey(parsed.host, parsed.port); + if (seen.has(key)) continue; + seen.set(key, { + host: parsed.host, + port: parsed.port, + url: parsed.url, + processName: null, + pid: null, + terminal: null, + source: "configured", + listening: false, + }); + } + + for (const server of input.scanner) { + const key = canonicalKey(server.host, server.port); + const existing = seen.get(key); + if (existing) { + // Enrich a configured entry with live process metadata; flip + // `listening` so it pulses green like a scanner-discovered entry. + seen.set(key, { + ...existing, + processName: server.processName ?? existing.processName, + pid: server.pid ?? existing.pid, + terminal: server.terminal ?? existing.terminal, + listening: true, + }); + continue; + } + seen.set(key, { ...server, source: "scanner", listening: true }); + } + + for (const url of input.recentlySeenUrls) { + const parsed = parseLocalUrl(url); + if (!parsed) continue; + const key = canonicalKey(parsed.host, parsed.port); + if (seen.has(key)) continue; + seen.set(key, { + host: parsed.host, + port: parsed.port, + url: parsed.url, + processName: null, + pid: null, + terminal: null, + source: "recent", + listening: false, + }); + } + + return Array.from(seen.values()).toSorted((a, b) => { + const sourceOrder: Record = { + configured: 0, + scanner: 1, + recent: 2, + }; + if (sourceOrder[a.source] !== sourceOrder[b.source]) { + return sourceOrder[a.source] - sourceOrder[b.source]; + } + return a.port - b.port; + }); +} + +function canonicalKey(host: string, port: number): string { + return `${host.toLowerCase()}:${port}`; +} + +function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (!isLoopbackHost(parsed.hostname)) return null; + const port = parsed.port + ? Number.parseInt(parsed.port, 10) + : parsed.protocol === "http:" + ? 80 + : 443; + if (!Number.isFinite(port) || port <= 0) return null; + return { host: parsed.hostname, port, url: parsed.href }; + } catch { + return null; + } +} diff --git a/apps/web/src/components/preview/useLoadingProgress.ts b/apps/web/src/components/preview/useLoadingProgress.ts new file mode 100644 index 00000000000..6c47cea6017 --- /dev/null +++ b/apps/web/src/components/preview/useLoadingProgress.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from "react"; + +const TICK_INTERVAL_MS = 120; +const FADE_OUT_DELAY_MS = 220; +const SEED_PERCENT = 4; +const ASYMPTOTE_PERCENT = 90; +const APPROACH_FACTOR = 0.08; +const MIN_INCREMENT = 0.5; + +/** + * Indeterminate progress simulator for the preview chrome's loading bar. + * Animates 0 → 90% asymptotically while `loading` is true, snaps to 100% + * on release, then resets after a short pause. + * + * Uses a ref to thread the latest progress through interval ticks without + * needing `loading` to retrigger the effect, which sidesteps the stale- + * closure pitfalls of reading `progress` directly. + */ +export function useLoadingProgress(loading: boolean): number { + const [progress, setProgress] = useState(0); + const progressRef = useRef(0); + progressRef.current = progress; + + useEffect(() => { + if (!loading) { + if (progressRef.current === 0) return; + setProgress(100); + const timer = window.setTimeout(() => setProgress(0), FADE_OUT_DELAY_MS); + return () => window.clearTimeout(timer); + } + + setProgress((value) => (value > 0 && value < 95 ? value : SEED_PERCENT)); + const interval = window.setInterval(() => { + const current = progressRef.current; + if (current >= ASYMPTOTE_PERCENT) return; + const remaining = ASYMPTOTE_PERCENT - current; + const increment = Math.max(MIN_INCREMENT, remaining * APPROACH_FACTOR); + setProgress(Math.min(ASYMPTOTE_PERCENT, current + increment)); + }, TICK_INTERVAL_MS); + + return () => window.clearInterval(interval); + }, [loading]); + + return progress; +} diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts new file mode 100644 index 00000000000..a68806d6aaf --- /dev/null +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -0,0 +1,147 @@ +"use client"; + +import type { + DesktopPreviewTabState, + PreviewReportStatusInput, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { useEffect, useRef } from "react"; + +import { useBrowserPointerStore } from "~/browser/browserPointerStore"; +import { type DesktopPreviewOverlay, usePreviewStateStore } from "~/previewStateStore"; +import { usePreviewActions } from "~/state/preview"; + +import { previewBridge } from "./previewBridge"; + +/** + * Mirrors low-latency desktop state into the store and reflects navigation + * events back to the server. Webview lifetime is owned by ElectronBrowserHost. + */ +export function usePreviewBridge(input: { threadRef: ScopedThreadRef; tabId: string }): void { + const { threadRef, tabId } = input; + const applyDesktopState = usePreviewStateStore((state) => state.applyDesktopState); + const clearBrowserPointer = useBrowserPointerStore((state) => state.clear); + const { reportStatus } = usePreviewActions(); + const bridge = previewBridge; + + // One bridge subscription does both jobs (mirror state + forward to + // server) so the desktop bridge keeps a single listener entry per tab. + const lastReportedUrl = useRef(null); + const lastReportedKind = useRef(null); + const lastDesktopNavStatus = useRef(null); + useEffect(() => { + if (!bridge || typeof window === "undefined") return; + lastReportedUrl.current = null; + lastReportedKind.current = null; + lastDesktopNavStatus.current = null; + const unsubscribe = bridge.onStateChange((changedTabId, state) => { + if (changedTabId !== tabId) return; + if (shouldClearBrowserPointer(lastDesktopNavStatus.current, state.navStatus)) { + clearBrowserPointer(tabId); + } + lastDesktopNavStatus.current = state.navStatus; + applyDesktopState(threadRef, tabId, projectDesktopState(state)); + const reported = buildReportInput({ + threadId: threadRef.threadId, + tabId, + state, + lastReportedUrl: lastReportedUrl.current, + lastReportedKind: lastReportedKind.current, + }); + if (!reported) return; + lastReportedUrl.current = reported.lastReportedUrl; + lastReportedKind.current = reported.lastReportedKind; + void reportStatus({ + environmentId: threadRef.environmentId, + input: reported.input, + }).catch(() => undefined); + }); + return unsubscribe; + }, [applyDesktopState, bridge, clearBrowserPointer, reportStatus, tabId, threadRef]); +} + +function shouldClearBrowserPointer( + previous: DesktopPreviewTabState["navStatus"] | null, + current: DesktopPreviewTabState["navStatus"], +): boolean { + if (!previous) return false; + if (current.kind === "Loading" && previous.kind !== "Loading") return true; + if (current.kind === "Idle" || previous.kind === "Idle") return false; + return current.url !== previous.url; +} + +function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverlay { + return { + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + loading: state.navStatus.kind === "Loading", + zoomFactor: state.zoomFactor, + controller: state.controller, + }; +} + +/** + * Decide whether a state change warrants an RPC to the server, and shape + * the report payload. + * + * - Idle never reports — the tab is post-close or pre-load and the server + * already knows the canonical state from `open` / `closed`. + * - We dedupe on (kind, url): consecutive Loading→Loading→Loading for the + * same URL collapses to a single RPC, ditto Success. + * - LoadFailed always reports (the server uses it to emit `failed`). + */ +function buildReportInput(args: { + readonly threadId: ThreadId; + readonly tabId: string; + readonly state: DesktopPreviewTabState; + readonly lastReportedUrl: string | null; + readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"] | null; +}): { + readonly input: PreviewReportStatusInput; + readonly lastReportedUrl: string; + readonly lastReportedKind: DesktopPreviewTabState["navStatus"]["kind"]; +} | null { + const { threadId, tabId, state, lastReportedUrl, lastReportedKind } = args; + const status = state.navStatus; + if (status.kind === "Idle") return null; + + // Skip if we've already reported the same kind+url. LoadFailed always + // reports (rapid duplicate failures are unusual and worth surfacing). + const sameAsLast = + status.kind !== "LoadFailed" && + status.kind === lastReportedKind && + status.url === lastReportedUrl; + if (sameAsLast) return null; + + const base = { + threadId, + tabId, + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + }; + if (status.kind === "LoadFailed") { + return { + input: { + ...base, + navStatus: { + _tag: "LoadFailed", + url: status.url, + title: status.title, + code: status.code, + description: status.description, + }, + }, + lastReportedUrl: status.url, + lastReportedKind: "LoadFailed", + }; + } + return { + input: { + ...base, + navStatus: { _tag: status.kind, url: status.url, title: status.title }, + }, + lastReportedUrl: status.url, + lastReportedKind: status.kind, + }; +} diff --git a/apps/web/src/components/preview/usePreviewSession.ts b/apps/web/src/components/preview/usePreviewSession.ts new file mode 100644 index 00000000000..36fb47c62ae --- /dev/null +++ b/apps/web/src/components/preview/usePreviewSession.ts @@ -0,0 +1,74 @@ +"use client"; + +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useEffect } from "react"; + +import { readPreviewStateRevision, usePreviewStateStore } from "~/previewStateStore"; +import { previewEnvironment, usePreviewActions } from "~/state/preview"; +import { useEnvironmentQuery } from "~/state/query"; + +import { refreshPreviewSessionState, usePreviewSessionState } from "./previewSessionState"; + +export function usePreviewSession(threadRef: ScopedThreadRef): void { + const query = usePreviewSessionState(threadRef); + const events = useEnvironmentQuery( + previewEnvironment.events({ + environmentId: threadRef.environmentId, + input: {}, + }), + ); + const { open } = usePreviewActions(); + const applyServerSnapshot = usePreviewStateStore((state) => state.applyServerSnapshot); + const applyServerEvent = usePreviewStateStore((state) => state.applyServerEvent); + + useEffect(() => { + if ( + query.isPending || + !query.data || + query.data.revision !== readPreviewStateRevision(threadRef) + ) { + return; + } + let cancelled = false; + if (query.data.result.sessions.length > 0) { + for (const snapshot of query.data.result.sessions) { + applyServerSnapshot(threadRef, snapshot); + } + return; + } + + const localSnapshot = + usePreviewStateStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.snapshot; + const recoverableUrl = + localSnapshot && localSnapshot.navStatus._tag !== "Idle" ? localSnapshot.navStatus.url : null; + if (!recoverableUrl) { + applyServerSnapshot(threadRef, null); + return; + } + + void open({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, url: recoverableUrl }, + }) + .then((snapshot) => { + if (cancelled) return; + applyServerSnapshot(threadRef, snapshot); + refreshPreviewSessionState(threadRef); + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, [applyServerSnapshot, open, query.data, query.isPending, threadRef]); + + useEffect(() => { + const event = events.data; + if (!event || event.threadId !== threadRef.threadId) return; + applyServerEvent(threadRef, event); + if (event.type === "opened" || event.type === "closed") { + refreshPreviewSessionState(threadRef); + } + }, [applyServerEvent, events.data, threadRef]); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c85a0b6878c..e77747b6d0b 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -8,6 +8,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useAuth } from "@clerk/react"; +import { useAtomSet } from "@effect/atom-react"; import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { AuthAccessReadScope, @@ -29,9 +30,11 @@ import { type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; -import { WsRpcClient } from "@t3tools/client-runtime"; +import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; @@ -76,7 +79,6 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; -import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { Group, GroupSeparator } from "../ui/group"; import { AnimatedHeight } from "../AnimatedHeight"; import { @@ -97,40 +99,27 @@ import { revokeServerClientSession, revokeServerPairingLink, isLoopbackHostname, - usePrimaryEnvironmentId, usePrimarySessionState, type ServerClientSessionRecord, type ServerPairingLinkRecord, } from "~/environments/primary"; -import { - type SavedEnvironmentRecord, - type SavedEnvironmentRuntimeState, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - addSavedEnvironment, - addManagedRelayEnvironment, - connectDesktopSshEnvironment, - disconnectSavedEnvironment, - getPrimaryEnvironmentConnection, - reconnectSavedEnvironment, - removeSavedEnvironment, -} from "~/environments/runtime"; import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch } from "~/versionSkew"; -import { useServerConfig } from "~/rpc/serverState"; -import { - connectManagedCloudEnvironment, - linkPrimaryEnvironmentToCloud, - unlinkPrimaryEnvironmentFromCloud, - updatePrimaryCloudPreferences, -} from "~/cloud/linkEnvironment"; -import { - refreshManagedRelayEnvironments, - useManagedRelayEnvironments, -} from "~/cloud/managedRelayState"; import { usePrimaryCloudLinkState } from "~/cloud/primaryCloudLinkState"; -import { webRuntime } from "~/lib/runtime"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; +import { + linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, + unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, +} from "~/cloud/linkEnvironmentAtoms"; +import { authEnvironment } from "~/state/auth"; +import { useEnvironmentQuery } from "~/state/query"; +import { + type EnvironmentPresentation, + useEnvironmentActions, + useEnvironments, + usePrimaryEnvironment, + useRelayEnvironmentDiscovery, +} from "~/state/environments"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; @@ -274,6 +263,7 @@ function ConnectionStatusDot({ const dot = (
- - } - > - {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} - · - - - {expiresAbsolute} - +

+ {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} + · + +

{shareablePairingUrl === null ? (

Copy the token and pair from another client using this backend's reachable host. @@ -902,26 +862,17 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ <> {shareablePairingUrl ? ( - - - } - > - - Copy pairing URL for: {defaultEndpointCopyLabel} - - - +

{shouldShowEndpointUrl ? ( - - - } - > - {endpoint.httpBaseUrl} - - {endpoint.httpBaseUrl} - +

+ {endpoint.httpBaseUrl} +

) : null} {!isAvailable ? ( @@ -1471,54 +1415,42 @@ function NetworkAccessDescription({ } type SavedBackendListRowProps = { - environmentId: EnvironmentId; - reconnectingEnvironmentId: EnvironmentId | null; - disconnectingEnvironmentId: EnvironmentId | null; + environment: EnvironmentPresentation; removingEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; - onDisconnect: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; }; function SavedBackendListRow({ - environmentId, - reconnectingEnvironmentId, - disconnectingEnvironmentId, + environment, removingEnvironmentId, onConnect, - onDisconnect, onRemove, }: SavedBackendListRowProps) { - const nowMs = useRelativeTimeTick(1_000); - const record = useSavedEnvironmentRegistryStore((state) => state.byId[environmentId] ?? null); - const runtime = useSavedEnvironmentRuntimeStore((state) => state.byId[environmentId] ?? null); - - if (!record) { - return null; - } - - const connectionState = runtime?.connectionState ?? "disconnected"; + const environmentId = environment.environmentId; + const connectionState = environment.connection.phase; const isConnected = connectionState === "connected"; - const isConnecting = - connectionState === "connecting" || reconnectingEnvironmentId === environmentId; - const isDisconnecting = disconnectingEnvironmentId === environmentId; + const isConnecting = connectionState === "connecting" || connectionState === "reconnecting"; const stateDotClassName = connectionState === "connected" ? "bg-success" - : connectionState === "connecting" + : connectionState === "connecting" || connectionState === "reconnecting" ? "bg-warning" : connectionState === "error" ? "bg-destructive" : "bg-muted-foreground/40"; - const descriptorLabel = runtime?.descriptor?.label ?? null; - const displayLabel = descriptorLabel ?? record.label; - const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); - const versionMismatch = resolveServerConfigVersionMismatch(runtime?.serverConfig); + const statusTooltip = connectionStatusText(environment.connection); + const errorTraceId = environment.connection.traceId; + const versionMismatch = resolveServerConfigVersionMismatch(environment.serverConfig); + const sshTarget = + environment.entry.target._tag === "SshConnectionTarget" && + Option.isSome(environment.entry.profile) && + environment.entry.profile.value._tag === "SshConnectionProfile" + ? environment.entry.profile.value.target + : null; const metadataBits = [ - record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, - record.lastConnectedAt - ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` - : null, + sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + environment.relayManaged ? "T3 Cloud" : null, ].filter((value): value is string => value !== null); return ( @@ -1530,19 +1462,15 @@ function SavedBackendListRow({ tooltipText={statusTooltip} dotClassName={stateDotClassName} pingClassName={ - connectionState === "connecting" ? "bg-warning/60 duration-2000" : null + connectionState === "connecting" || connectionState === "reconnecting" + ? "bg-warning/60 duration-2000" + : null } /> -

{displayLabel}

+

{environment.label}

- {metadataBits.length > 0 || runtime?.scopes ? ( -

- {metadataBits.length > 0 ? metadataBits.join(" · ") : null} - {metadataBits.length > 0 && runtime?.scopes ? · : null} - {runtime?.scopes ? ( - - ) : null} -

+ {metadataBits.length > 0 ? ( +

{metadataBits.join(" · ")}

) : null} {versionMismatch ? (

@@ -1551,32 +1479,36 @@ function SavedBackendListRow({ {versionMismatch.serverVersion}.

) : null} + {environment.connection.error ? ( +

+ {connectionStatusText(environment.connection)} + {errorTraceId ? ( + + ) : null} +

+ ) : null}
-
@@ -1636,7 +1568,7 @@ function CloudLinkSwitch({ }) { const control = ( (null); const [isUpdating, setIsUpdating] = useState(false); - const [isUpdatingPreference, setIsUpdatingPreference] = useState(false); const updateLink = async (enabled: boolean) => { setIsUpdating(true); @@ -1666,116 +1601,81 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b try { const clerkToken = await getToken(resolveRelayClerkTokenOptions()); if (enabled) { + if (!primaryCloudLinkState.target) { + throw new Error("Local environment is not ready yet."); + } if (!clerkToken) { - throw new Error("Sign in to T3 Connect before linking this environment."); + throw new Error("Sign in from T3 Cloud settings before linking this environment."); } - await webRuntime.runPromise(linkPrimaryEnvironmentToCloud({ clerkToken })); + await linkPrimaryEnvironment({ + target: primaryCloudLinkState.target, + clerkToken, + }); } else { - await webRuntime.runPromise( - unlinkPrimaryEnvironmentFromCloud({ clerkToken: clerkToken ?? null }), - ); + if (!primaryCloudLinkState.target) { + throw new Error("Local environment is not ready yet."); + } + await unlinkPrimaryEnvironment({ + target: primaryCloudLinkState.target, + clerkToken: clerkToken ?? null, + }); } primaryCloudLinkState.refresh(); - refreshManagedRelayEnvironments(); + await refreshRelayEnvironments(); toastManager.add({ type: "success", - title: enabled ? "T3 Connect linked" : "T3 Connect unlinked", + title: enabled ? "T3 Cloud linked" : "T3 Cloud unlinked", description: enabled - ? "This environment is available through T3 Connect." - : "This environment is no longer available through T3 Connect.", + ? "This environment is available through T3 Cloud." + : "This environment is no longer available through T3 Cloud.", }); } catch (cause) { - const message = - cause instanceof Error ? cause.message : "Could not update T3 Connect access."; - setOperationError(message); + const message = cause instanceof Error ? cause.message : "Could not update T3 Cloud access."; + const traceId = findErrorTraceId(cause); + console.error("[t3-cloud] Could not update T3 Cloud", { message, traceId, cause }); + setOperationError(traceId ? `${message} Trace ID: ${traceId}` : message); toastManager.add({ type: "error", - title: "Could not update T3 Connect", + title: "Could not update T3 Cloud", description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, }); } finally { setIsUpdating(false); } }; - const updatePublishAgentActivity = async (enabled: boolean) => { - setIsUpdatingPreference(true); - try { - await webRuntime.runPromise(updatePrimaryCloudPreferences({ publishAgentActivity: enabled })); - primaryCloudLinkState.refresh(); - toastManager.add({ - type: "success", - title: enabled ? "Agent activity enabled" : "Agent activity disabled", - description: enabled - ? "This environment can publish agent activity to your notification devices." - : "This environment will stop publishing agent activity.", - }); - } catch (cause) { - toastManager.add({ - type: "error", - title: "Could not update T3 Connect preferences", - description: - cause instanceof Error ? cause.message : "Could not update agent activity publishing.", - }); - } finally { - setIsUpdatingPreference(false); - } - }; const disabledReason = !isSignedIn - ? "Sign in to T3 Connect" + ? "Sign in from T3 Cloud settings to manage this environment." : !canManageRelay - ? "Your session does not have permission to manage T3 Connect access." + ? "Your session does not have permission to manage T3 Cloud access." : null; const linked = primaryCloudLinkState.data?.linked ?? false; return ( - <> - { - if (!isSignedIn) { - openAuthPrompt(); - return; - } - void updateLink(enabled); - }} - /> - } - /> - {linked ? ( - void updatePublishAgentActivity(enabled)} - /> - } + void updateLink(enabled)} /> - ) : null} - {authPrompt} - + } + /> ); } @@ -1783,13 +1683,7 @@ function CloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) return hasCloudPublicConfig() ? : null; } -function EmptyRemoteEnvironments({ - cloudEnabled = true, - onConnectFromCloud, -}: { - readonly cloudEnabled?: boolean; - readonly onConnectFromCloud?: () => void; -}) { +function EmptyRemoteEnvironments({ cloudEnabled = true }: { readonly cloudEnabled?: boolean }) { return ( @@ -1798,24 +1692,9 @@ function EmptyRemoteEnvironments({ No saved remote environments - Click “Add environment” to pair another environment - {cloudEnabled ? ( - <> - , or connect one from{" "} - {onConnectFromCloud ? ( - - ) : ( - "T3 Connect" - )} - - ) : null} - . + {cloudEnabled + ? "Click “Add environment” to pair another environment, or connect one from T3 Cloud." + : "Click “Add environment” to pair another environment."} @@ -1843,73 +1722,111 @@ function ConfiguredCloudRemoteEnvironmentRows({ readonly primaryEnvironmentId: EnvironmentId | null; readonly savedEnvironmentIds: ReadonlyArray; }) { - const { getToken, isSignedIn } = useAuth(); - const { authPrompt, openAuthPrompt } = useT3ConnectAuthPrompt(); - const environmentsState = useManagedRelayEnvironments(); + const environmentsState = useRelayEnvironmentDiscovery(); + const { connectRelayEnvironment, refreshRelayEnvironments } = useEnvironmentActions(); const [connectingEnvironmentId, setConnectingEnvironmentId] = useState( null, ); const savedIds = useMemo(() => new Set(savedEnvironmentIds), [savedEnvironmentIds]); + useEffect(() => { + void refreshRelayEnvironments().catch(() => { + // The discovery state carries the typed failure for presentation. + }); + }, [refreshRelayEnvironments]); + const connectEnvironment = async (environment: RelayClientEnvironmentRecord) => { setConnectingEnvironmentId(environment.environmentId); try { - const clerkToken = await getToken(resolveRelayClerkTokenOptions()); - if (!clerkToken) { - throw new Error("Sign in to T3 Connect before connecting this environment."); - } - const connection = await webRuntime.runPromise( - connectManagedCloudEnvironment({ clerkToken, environment }), - ); - await addManagedRelayEnvironment(connection); + await connectRelayEnvironment(environment); toastManager.add({ type: "success", title: "Environment connected", - description: `${connection.label} is available through T3 Connect.`, + description: `${environment.label} is available through T3 Cloud.`, }); } catch (cause) { + const message = + cause instanceof Error ? cause.message : "Could not connect the T3 Cloud environment."; + const traceId = findErrorTraceId(cause); + console.error("[t3-cloud] Could not connect environment", { message, traceId, cause }); toastManager.add({ type: "error", title: "Could not connect environment", - description: - cause instanceof Error ? cause.message : "Could not connect the T3 Connect environment.", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, }); } finally { setConnectingEnvironmentId(null); } }; - const connectableEnvironments = (environmentsState.data ?? []).filter( - (environment) => + const connectableEnvironments = [...environmentsState.environments.values()].filter( + ({ environment }) => environment.environmentId !== primaryEnvironmentId && !savedIds.has(environment.environmentId), ); - if (savedEnvironmentIds.length === 0 && environmentsState.data === null) { + if ( + savedEnvironmentIds.length === 0 && + environmentsState.refreshing && + environmentsState.environments.size === 0 + ) { return ; } if (savedEnvironmentIds.length === 0 && connectableEnvironments.length === 0) { - return ( - <> - - {authPrompt} - - ); + return ; } - return connectableEnvironments.map((environment) => ( + return connectableEnvironments.map(({ environment, availability, error }) => (

{environment.label}

-

T3 Connect

+

+ {availability === "online" + ? "Available · Relay online" + : availability === "offline" + ? "Available · Relay offline" + : availability === "checking" + ? "Available · Checking relay status…" + : (Option.getOrNull(error)?.message ?? "Available · Relay status unavailable")} +

", + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + styles: ".submit { color: white; }", + } as const; + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("adds an element context and stamps id + threadId + pickedAt", () => { + const accepted = useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); + expect(accepted).toBe(true); + const draft = draftFor(threadId, TEST_ENVIRONMENT_ID); + expect(draft?.elementContexts).toHaveLength(1); + const entry = draft?.elementContexts[0]!; + expect(entry.id.startsWith("el_")).toBe(true); + expect(entry.threadId).toBe(threadId); + expect(entry.pickedAt.length).toBeGreaterThan(0); + expect(entry.componentName).toBe("SubmitButton"); + }); + + it("dedupes by selector + tag + componentName + pageUrl signature", () => { + const store = useComposerDraftStore.getState(); + expect(store.addElementContext(threadRef, baseSelection)).toBe(true); + const second = store.addElementContext(threadRef, { + ...baseSelection, + htmlPreview: "", + }); + expect(second).toBe(false); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts).toHaveLength(1); + }); + + it("removeElementContext drops by id + leaves siblings intact", () => { + const store = useComposerDraftStore.getState(); + store.addElementContext(threadRef, baseSelection); + store.addElementContext(threadRef, { ...baseSelection, selector: "button.cancel" }); + const ids = draftFor(threadId, TEST_ENVIRONMENT_ID)!.elementContexts.map((c) => c.id); + store.removeElementContext(threadRef, ids[0]!); + const remaining = draftFor(threadId, TEST_ENVIRONMENT_ID)?.elementContexts; + expect(remaining?.map((c) => c.id)).toEqual([ids[1]]); + }); + + it("setElementContexts replaces the slice and clearComposerContent wipes it", () => { + const store = useComposerDraftStore.getState(); + store.addElementContext(threadRef, baseSelection); + store.setElementContexts(threadRef, []); + // Fully empty draft should be removed via shouldRemoveDraft. + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + + store.addElementContext(threadRef, baseSelection); + store.clearComposerContent(threadRef); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); + + it("persists element contexts via the partializer (round-trippable)", () => { + useComposerDraftStore.getState().addElementContext(threadRef, baseSelection); + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + }; + }; + const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + draftsByThreadKey?: Record> }>; + }; + const entry = + persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.elementContexts?.[0]; + expect(entry).toMatchObject({ + pageUrl: baseSelection.pageUrl, + tagName: baseSelection.tagName, + selector: baseSelection.selector, + componentName: baseSelection.componentName, + }); + // Persistence does NOT include htmlPreview / styles oversize-clamping — + // that happens at normalization time, before the value reaches the store. + expect(typeof entry?.htmlPreview).toBe("string"); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.make("project-a"); const otherProjectId = ProjectId.make("project-b"); @@ -540,6 +634,40 @@ describe("composerDraftStore project draft thread mapping", () => { resetComposerDraftStore(); }); + it("clears composer data for one environment without touching another", () => { + const store = useComposerDraftStore.getState(); + const localThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const remoteThreadRef = scopeThreadRef(OTHER_TEST_ENVIRONMENT_ID, otherThreadId); + const originalRevokeObjectUrl = URL.revokeObjectURL; + const revokeSpy = vi.fn<(url: string) => void>(); + URL.revokeObjectURL = revokeSpy; + + try { + store.setProjectDraftThreadId(projectRef, localDraftId, { threadId }); + store.setProjectDraftThreadId(remoteProjectRef, remoteDraftId, { + threadId: otherThreadId, + }); + store.setPrompt(localDraftId, "local draft"); + store.setPrompt(remoteDraftId, "remote draft"); + store.addImage(localDraftId, makeImage({ id: "img-local", previewUrl: "blob:local-draft" })); + store.setPrompt(localThreadRef, "local thread draft"); + store.setPrompt(remoteThreadRef, "remote thread draft"); + + clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID); + + const next = useComposerDraftStore.getState(); + expect(next.getDraftThreadByProjectRef(projectRef)).toBeNull(); + expect(next.getDraftThreadByProjectRef(remoteProjectRef)).not.toBeNull(); + expect(next.getComposerDraft(localDraftId)).toBeNull(); + expect(next.getComposerDraft(remoteDraftId)?.prompt).toBe("remote thread draft"); + expect(next.getComposerDraft(localThreadRef)).toBeNull(); + expect(next.getComposerDraft(remoteThreadRef)?.prompt).toBe("remote thread draft"); + expect(revokeSpy).toHaveBeenCalledWith("blob:local-draft"); + } finally { + URL.revokeObjectURL = originalRevokeObjectUrl; + } + }); + it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3de3b5d706d..42ab91d434d 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -9,6 +9,8 @@ import { ProviderInteractionMode, ProviderDriverKind, ProviderOptionSelection, + PreviewAnnotationPayloadSchema, + type PreviewAnnotationPayload, RuntimeMode, type ServerProvider, type ScopedProjectRef, @@ -22,7 +24,7 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; @@ -36,6 +38,12 @@ import { ensureInlineTerminalContextPlaceholders, normalizeTerminalContextText, } from "./lib/terminalContext"; +import { + type ElementContextDraft, + type ElementContextSelection, + elementContextDedupKey, + newElementContextId, +} from "./lib/elementContext"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -46,7 +54,7 @@ const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 6; +const COMPOSER_DRAFT_STORAGE_VERSION = 7; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -92,10 +100,34 @@ const PersistedTerminalContextDraft = Schema.Struct({ }); type PersistedTerminalContextDraft = typeof PersistedTerminalContextDraft.Type; +const PersistedElementContextStackFrame = Schema.Struct({ + functionName: Schema.NullOr(Schema.String), + fileName: Schema.NullOr(Schema.String), + lineNumber: Schema.NullOr(Schema.Number), + columnNumber: Schema.NullOr(Schema.Number), +}); + +const PersistedElementContextDraft = Schema.Struct({ + id: Schema.String, + threadId: ThreadId, + pickedAt: Schema.String, + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + tagName: Schema.String, + selector: Schema.NullOr(Schema.String), + htmlPreview: Schema.String, + componentName: Schema.NullOr(Schema.String), + source: Schema.NullOr(PersistedElementContextStackFrame), + styles: Schema.String, +}); +type PersistedElementContextDraft = typeof PersistedElementContextDraft.Type; + const PersistedComposerThreadDraftState = Schema.Struct({ prompt: Schema.String, attachments: Schema.Array(PersistedComposerImageAttachment), terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), + elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), + previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` @@ -216,6 +248,14 @@ export interface ComposerThreadDraftState { nonPersistedImageIds: string[]; persistedAttachments: PersistedComposerImageAttachment[]; terminalContexts: TerminalContextDraft[]; + /** + * Element-pick attachments captured from the in-app preview browser. The + * full payload (selector / html / styles / source frame) is persisted + * inline because — unlike terminal contexts — there's no live session to + * re-derive the snapshot from on reload. + */ + elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored @@ -397,6 +437,33 @@ interface ComposerDraftStoreState { addTerminalContexts: (threadRef: ComposerThreadTarget, contexts: TerminalContextDraft[]) => void; removeTerminalContext: (threadRef: ComposerThreadTarget, contextId: string) => void; clearTerminalContexts: (threadRef: ComposerThreadTarget) => void; + /** + * Append a fresh element pick to the draft. Returns true when accepted, + * false when deduped against an existing pick of the same element. + */ + addElementContext: ( + threadRef: ComposerThreadTarget, + selection: ElementContextSelection, + ) => boolean; + /** + * Replace the entire element-contexts list (used by send-failure retry to + * restore the pre-send snapshot). + */ + setElementContexts: ( + threadRef: ComposerThreadTarget, + contexts: ReadonlyArray, + ) => void; + removeElementContext: (threadRef: ComposerThreadTarget, contextId: string) => void; + clearElementContexts: (threadRef: ComposerThreadTarget) => void; + addPreviewAnnotation: ( + threadRef: ComposerThreadTarget, + annotation: PreviewAnnotationPayload, + ) => void; + setPreviewAnnotations: ( + threadRef: ComposerThreadTarget, + annotations: ReadonlyArray, + ) => void; + removePreviewAnnotation: (threadRef: ComposerThreadTarget, annotationId: string) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, @@ -472,9 +539,13 @@ const EMPTY_IMAGES: ComposerImageAttachment[] = []; const EMPTY_IDS: string[] = []; const EMPTY_PERSISTED_ATTACHMENTS: PersistedComposerImageAttachment[] = []; const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; +const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; +const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); +Object.freeze(EMPTY_ELEMENT_CONTEXTS); +Object.freeze(EMPTY_PREVIEW_ANNOTATIONS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ @@ -488,19 +559,29 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ nonPersistedImageIds: EMPTY_IDS, persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, terminalContexts: EMPTY_TERMINAL_CONTEXTS, + elementContexts: EMPTY_ELEMENT_CONTEXTS, + previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, interactionMode: null, }); -function createEmptyThreadDraft(): ComposerThreadDraftState { +/** + * Canonical factory for a blank `ComposerThreadDraftState`. Exported so tests + * (and any other call sites) can build a draft without re-declaring every + * slice — adding a new field to the interface (e.g. `elementContexts`) only + * has to be reflected here, not in every stub. + */ +export function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + elementContexts: [], + previewAnnotations: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -571,6 +652,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.images.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.elementContexts.length === 0 && + draft.previewAnnotations.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -964,6 +1047,63 @@ function normalizePersistedAttachment(value: unknown): PersistedComposerImageAtt }; } +function normalizePersistedElementContextDraft( + value: unknown, +): PersistedElementContextDraft | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Record; + const id = candidate.id; + const threadId = candidate.threadId; + const pickedAt = candidate.pickedAt; + const pageUrl = candidate.pageUrl; + const tagName = candidate.tagName; + if ( + typeof id !== "string" || + id.length === 0 || + typeof threadId !== "string" || + threadId.length === 0 || + typeof pickedAt !== "string" || + pickedAt.length === 0 || + typeof pageUrl !== "string" || + pageUrl.length === 0 || + typeof tagName !== "string" || + tagName.length === 0 + ) { + return null; + } + const sourceCandidate = candidate.source; + let source: PersistedElementContextDraft["source"] = null; + if (sourceCandidate && typeof sourceCandidate === "object") { + const sourceRecord = sourceCandidate as Record; + source = { + functionName: + typeof sourceRecord.functionName === "string" ? sourceRecord.functionName : null, + fileName: typeof sourceRecord.fileName === "string" ? sourceRecord.fileName : null, + lineNumber: + typeof sourceRecord.lineNumber === "number" && Number.isFinite(sourceRecord.lineNumber) + ? sourceRecord.lineNumber + : null, + columnNumber: + typeof sourceRecord.columnNumber === "number" && Number.isFinite(sourceRecord.columnNumber) + ? sourceRecord.columnNumber + : null, + }; + } + return { + id, + threadId: threadId as ThreadId, + pickedAt, + pageUrl, + pageTitle: typeof candidate.pageTitle === "string" ? candidate.pageTitle : null, + tagName, + selector: typeof candidate.selector === "string" ? candidate.selector : null, + htmlPreview: typeof candidate.htmlPreview === "string" ? candidate.htmlPreview : "", + componentName: typeof candidate.componentName === "string" ? candidate.componentName : null, + source, + styles: typeof candidate.styles === "string" ? candidate.styles : "", + }; +} + function normalizePersistedTerminalContextDraft( value: unknown, ): PersistedTerminalContextDraft | null { @@ -1478,6 +1618,12 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const elementContexts = Array.isArray(draftCandidate.elementContexts) + ? draftCandidate.elementContexts.flatMap((entry) => { + const normalized = normalizePersistedElementContextDraft(entry); + return normalized ? [normalized] : []; + }) + : []; const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode) ? draftCandidate.runtimeMode : null; @@ -1541,6 +1687,7 @@ function normalizePersistedDraftsByThreadId( promptCandidate.length === 0 && attachments.length === 0 && terminalContexts.length === 0 && + elementContexts.length === 0 && !hasModelData && !runtimeMode && !interactionMode @@ -1563,6 +1710,7 @@ function normalizePersistedDraftsByThreadId( prompt, attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), + ...(elementContexts.length > 0 ? { elementContexts } : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), @@ -1645,6 +1793,8 @@ function partializeComposerDraftStoreState( draft.prompt.length === 0 && draft.persistedAttachments.length === 0 && draft.terminalContexts.length === 0 && + draft.elementContexts.length === 0 && + draft.previewAnnotations.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -1667,6 +1817,30 @@ function partializeComposerDraftStoreState( })), } : {}), + ...(draft.elementContexts.length > 0 + ? { + elementContexts: draft.elementContexts.map((context) => ({ + id: context.id, + threadId: context.threadId, + pickedAt: context.pickedAt, + pageUrl: context.pageUrl, + pageTitle: context.pageTitle, + tagName: context.tagName, + selector: context.selector, + htmlPreview: context.htmlPreview, + componentName: context.componentName, + source: context.source, + styles: context.styles, + })), + } + : {}), + ...(draft.previewAnnotations.length > 0 + ? { + previewAnnotations: draft.previewAnnotations.map( + (annotation) => ({ ...annotation }) as DeepMutable, + ), + } + : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider( @@ -1903,6 +2077,12 @@ function toHydratedThreadDraft( ...context, text: "", })) ?? [], + elementContexts: + persistedDraft.elementContexts?.map((context) => ({ + ...context, + })) ?? [], + previewAnnotations: + persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -2815,6 +2995,159 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addElementContext: (threadRef, selection) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + const threadId = resolveComposerThreadId(get(), threadRef); + if (!threadKey || !threadId) return false; + let accepted = false; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const dedupKey = elementContextDedupKey(selection); + if ( + existing.elementContexts.some((entry) => elementContextDedupKey(entry) === dedupKey) + ) { + return state; + } + accepted = true; + const draft: ElementContextDraft = { + ...selection, + id: newElementContextId(), + threadId, + pickedAt: new Date().toISOString(), + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + elementContexts: [...existing.elementContexts, draft], + }, + }, + }; + }); + return accepted; + }, + setElementContexts: (threadRef, contexts) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft: ComposerThreadDraftState = { + ...existing, + elementContexts: [...contexts], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + removeElementContext: (threadRef, contextId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0 || contextId.length === 0) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) return state; + const filtered = current.elementContexts.filter((entry) => entry.id !== contextId); + if (filtered.length === current.elementContexts.length) return state; + const nextDraft: ComposerThreadDraftState = { + ...current, + elementContexts: filtered, + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + clearElementContexts: (threadRef) => { + const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; + if (threadKey.length === 0) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current || current.elementContexts.length === 0) return state; + const nextDraft: ComposerThreadDraftState = { + ...current, + elementContexts: [], + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadKey[threadKey]; + } else { + nextDraftsByThreadKey[threadKey] = nextDraft; + } + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + addPreviewAnnotation: (threadRef, annotation) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextAnnotations = existing.previewAnnotations.filter( + (entry) => entry.id !== annotation.id, + ); + const compactAnnotation: PreviewAnnotationPayload = { + ...annotation, + screenshot: annotation.screenshot ? { ...annotation.screenshot, dataUrl: "" } : null, + }; + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + previewAnnotations: [...nextAnnotations, compactAnnotation], + }, + }, + }; + }); + }, + setPreviewAnnotations: (threadRef, annotations) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { ...existing, previewAnnotations: [...annotations] }, + }, + }; + }); + }, + removePreviewAnnotation: (threadRef, annotationId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey || !annotationId) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) return state; + const previewAnnotations = current.previewAnnotations.filter( + (entry) => entry.id !== annotationId, + ); + if (previewAnnotations.length === current.previewAnnotations.length) return state; + const nextDraft = { + ...current, + previewAnnotations, + images: current.images.filter((image) => image.id !== annotationId), + persistedAttachments: current.persistedAttachments.filter( + (image) => image.id !== annotationId, + ), + nonPersistedImageIds: current.nonPersistedImageIds.filter( + (imageId) => imageId !== annotationId, + ), + }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey]; + else nextDraftsByThreadKey[threadKey] = nextDraft; + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, clearPersistedAttachments: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -2887,6 +3220,8 @@ const composerDraftStore = create()( nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], + elementContexts: [], + previewAnnotations: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { @@ -2935,6 +3270,60 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; +export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { + useComposerDraftStore.setState((state) => { + const removedThreadKeys = new Set(); + + for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if (draftThread.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + for (const threadKey of Object.keys(state.draftsByThreadKey)) { + if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + for (const [logicalProjectKey, threadKey] of Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + )) { + if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { + removedThreadKeys.add(threadKey); + } + } + + const nextLogicalMappings = Object.fromEntries( + Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( + ([logicalProjectKey, threadKey]) => + parseScopedProjectKey(logicalProjectKey)?.environmentId !== environmentId && + !removedThreadKeys.has(threadKey), + ), + ) as Record; + const nextDraftThreads = Object.fromEntries( + Object.entries(state.draftThreadsByThreadKey).filter( + ([threadKey, draftThread]) => + draftThread.environmentId !== environmentId && !removedThreadKeys.has(threadKey), + ), + ) as Record; + const nextDrafts = Object.fromEntries( + Object.entries(state.draftsByThreadKey).filter(([threadKey, draft]) => { + if (!removedThreadKeys.has(threadKey)) { + return true; + } + revokeDraftThreadPreviewUrls(draft); + return false; + }), + ) as Record; + + return { + draftsByThreadKey: nextDrafts, + draftThreadsByThreadKey: nextDraftThreads, + logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + }; + }); + composerDebouncedStorage.flush(); +} + export function useComposerThreadDraft(threadRef: ComposerThreadTarget): ComposerThreadDraftState { return useComposerDraftStore((state) => { return getComposerDraftState(state, threadRef) ?? EMPTY_THREAD_DRAFT; diff --git a/apps/web/src/connection/catalog.ts b/apps/web/src/connection/catalog.ts new file mode 100644 index 00000000000..971fa891106 --- /dev/null +++ b/apps/web/src/connection/catalog.ts @@ -0,0 +1,5 @@ +import { createEnvironmentCatalogAtoms } from "@t3tools/client-runtime/state/connections"; + +import { connectionAtomRuntime } from "./runtime"; + +export const environmentCatalog = createEnvironmentCatalogAtoms(connectionAtomRuntime); diff --git a/apps/web/src/connection/onboarding.ts b/apps/web/src/connection/onboarding.ts new file mode 100644 index 00000000000..12aaf396eb3 --- /dev/null +++ b/apps/web/src/connection/onboarding.ts @@ -0,0 +1,25 @@ +import { ConnectionOnboarding } from "@t3tools/client-runtime/connection"; +import type { DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "./runtime"; + +export const connectPairing = connectionAtomRuntime + .fn<{ + readonly pairingUrl?: string; + readonly host?: string; + readonly pairingCode?: string; + }>()((input) => + ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerPairing(input))), + ) + .pipe(Atom.withLabel("web:connection:connect-pairing")); + +export const connectSshEnvironment = connectionAtomRuntime + .fn<{ + readonly target: DesktopSshEnvironmentTarget; + readonly label?: string; + }>()((input) => + ConnectionOnboarding.pipe(Effect.flatMap((onboarding) => onboarding.registerSsh(input))), + ) + .pipe(Atom.withLabel("web:connection:connect-ssh")); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts new file mode 100644 index 00000000000..16d0f7c2036 --- /dev/null +++ b/apps/web/src/connection/platform.ts @@ -0,0 +1,354 @@ +import { + ClientPresentation, + CloudSession, + EnvironmentOwnedDataCleanup, + PlatformConnectionSource, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "@t3tools/client-runtime/platform"; +import { + ConnectionBlockedError, + ConnectionTransientError, + ConnectionWakeups, + Connectivity, + mapRemoteEnvironmentError, + PrimaryConnectionRegistration, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; +import { AuthStandardClientScopes } from "@t3tools/contracts"; +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 Queue from "effect/Queue"; +import * as Schedule from "effect/Schedule"; +import * as Stream from "effect/Stream"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; + +import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; +import { readPrimaryEnvironmentTarget } from "../environments/primary/target"; +import { clearComposerDraftsEnvironment } from "../composerDraftStore"; +import { isHostedStaticApp } from "../hostedPairing"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; +import { connectionStorageLayer } from "./storage"; + +let nextObservedRpcRequestId = 0; + +function currentNetworkStatus(): "unknown" | "offline" | "online" { + if (typeof navigator === "undefined") { + return "unknown"; + } + return navigator.onLine ? "online" : "offline"; +} + +const connectivityLayer = Layer.succeed( + Connectivity, + Connectivity.of({ + status: Effect.sync(currentNetworkStatus), + changes: Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const online = () => Queue.offerUnsafe(queue, "online"); + const offline = () => Queue.offerUnsafe(queue, "offline"); + window.addEventListener("online", online); + window.addEventListener("offline", offline); + return { online, offline }; + }), + ({ online, offline }) => + Effect.sync(() => { + window.removeEventListener("online", online); + window.removeEventListener("offline", offline); + }), + ).pipe(Effect.asVoid), + ), + }), +); + +const wakeupsLayer = Layer.succeed( + ConnectionWakeups, + ConnectionWakeups.of({ + changes: Stream.merge( + Stream.callback<"application-active">((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const listener = () => { + if (document.visibilityState === "visible") { + Queue.offerUnsafe(queue, "application-active"); + } + }; + document.addEventListener("visibilitychange", listener); + return listener; + }), + (listener) => + Effect.sync(() => { + document.removeEventListener("visibilitychange", listener); + }), + ).pipe(Effect.asVoid), + ), + Stream.callback<"credentials-changed">((queue) => + Effect.acquireRelease( + Effect.sync(() => + appAtomRegistry.subscribe(managedRelaySessionAtom, () => { + Queue.offerUnsafe(queue, "credentials-changed"); + }), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ).pipe(Effect.asVoid), + ), + ), + }), +); + +function clientMetadata() { + const desktop = window.desktopBridge !== undefined; + const platform = navigator.platform.trim(); + return { + label: desktop ? "T3 Code Desktop" : "T3 Code Web", + deviceType: "desktop" as const, + ...(platform === "" ? {} : { os: platform }), + }; +} + +function sshPreparationError(cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + if (message.toLowerCase().includes("cancel")) { + return new ConnectionBlockedError({ + reason: "authentication", + message, + }); + } + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not prepare the SSH environment: ${message}`, + }); +} + +const capabilitiesLayer = Layer.effectContext( + Effect.sync(() => { + const presentation = ClientPresentation.of({ + metadata: clientMetadata(), + scopes: AuthStandardClientScopes, + }); + const cloudSession = CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "Sign in to T3 Cloud to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + message: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The T3 Cloud session is unavailable.", + }); + } + return token; + }), + }); + const identity = RelayDeviceIdentity.of({ + deviceId: Effect.succeed(Option.none()), + }); + const ssh = SshEnvironmentGateway.of({ + provision: Effect.fn("web.connectionPlatform.ssh.provision")(function* (target) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + message: "SSH environments are only available in the desktop app.", + }); + } + const bootstrap = yield* Effect.tryPromise({ + try: () => + bridge.ensureSshEnvironment(target, { + issuePairingToken: true, + }), + catch: sshPreparationError, + }); + if (bootstrap.pairingToken === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The SSH environment did not issue a pairing credential.", + }); + } + const { descriptor, access } = yield* Effect.all( + { + descriptor: Effect.tryPromise({ + try: () => bridge.fetchSshEnvironmentDescriptor(bootstrap.httpBaseUrl), + catch: sshPreparationError, + }), + access: Effect.tryPromise({ + try: () => + bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!), + catch: sshPreparationError, + }), + }, + { concurrency: "unbounded" }, + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + bootstrap, + bearerToken: access.access_token, + }; + }), + prepare: Effect.fn("web.connectionPlatform.ssh.prepare")(function* (input) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return yield* new ConnectionBlockedError({ + reason: "unsupported", + message: "SSH environments are only available in the desktop app.", + }); + } + const bootstrap = yield* Effect.tryPromise({ + try: () => + bridge.ensureSshEnvironment(input.target, { + issuePairingToken: true, + }), + catch: sshPreparationError, + }); + if (bootstrap.pairingToken === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The SSH environment did not issue a pairing credential.", + }); + } + const access = yield* Effect.tryPromise({ + try: () => + bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!), + catch: sshPreparationError, + }); + return { + bootstrap, + bearerToken: access.access_token, + }; + }), + disconnect: Effect.fn("web.connectionPlatform.ssh.disconnect")(function* (target) { + const bridge = window.desktopBridge; + if (bridge === undefined) { + return; + } + yield* Effect.tryPromise({ + try: () => bridge.disconnectSshEnvironment(target), + catch: (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not disconnect the SSH environment: ${String(cause)}`, + }), + }); + }), + }); + + return Context.make(CloudSession, cloudSession).pipe( + Context.add(RelayDeviceIdentity, identity), + Context.add(ClientPresentation, presentation), + Context.add(SshEnvironmentGateway, ssh), + ); + }), +); + +const loadPrimaryConnectionRegistration = Effect.fn( + "web.connectionPlatform.loadPrimaryConnectionRegistration", +)(function* () { + const resolved = readPrimaryEnvironmentTarget(); + if (resolved === null) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: "Unable to resolve the primary environment endpoint.", + }); + } + const descriptor = yield* fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolved.target.httpBaseUrl, + }).pipe( + Effect.provideService(FetchHttpClient.RequestInit, primaryEnvironmentRequestInit), + Effect.mapError(mapRemoteEnvironmentError), + ); + return new PrimaryConnectionRegistration({ + target: new PrimaryConnectionTarget({ + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: resolved.target.httpBaseUrl, + wsBaseUrl: resolved.target.wsBaseUrl, + }), + }); +}); + +const primaryRegistrationRetrySchedule = Schedule.exponential("1 second").pipe( + Schedule.either(Schedule.spaced("16 seconds")), +); + +const platformConnectionSourceLayer = Layer.effect( + PlatformConnectionSource, + Effect.gen(function* () { + if (isHostedStaticApp()) { + return PlatformConnectionSource.of({ + registrations: Stream.empty, + }); + } + const httpClient = yield* HttpClient.HttpClient; + return PlatformConnectionSource.of({ + registrations: Stream.fromEffect( + loadPrimaryConnectionRegistration().pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + ).pipe( + Stream.tapError((error) => + Effect.logWarning("Could not discover the primary environment.", { + error, + }), + ), + Stream.retry(primaryRegistrationRetrySchedule), + Stream.catchCause(() => Stream.empty), + ), + }); + }), +); + +const environmentOwnedDataCleanupLayer = Layer.succeed( + EnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Effect.sync(() => { + clearComposerDraftsEnvironment(environmentId); + }), + }), +); + +const rpcRequestObserverLayer = Layer.succeed( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: ({ environmentId, method }) => + Effect.sync(() => { + nextObservedRpcRequestId += 1; + const requestId = `${environmentId}:${nextObservedRpcRequestId}`; + trackRpcRequestSent(requestId, `${method} · ${environmentId}`); + return Effect.sync(() => { + acknowledgeRpcRequest(requestId); + }); + }), + }), +); + +export const connectionPlatformLayer = Layer.mergeAll( + connectionStorageLayer, + connectivityLayer, + wakeupsLayer, + capabilitiesLayer, + platformConnectionSourceLayer, + environmentOwnedDataCleanupLayer, + rpcRequestObserverLayer, +); diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts new file mode 100644 index 00000000000..3b1eade0818 --- /dev/null +++ b/apps/web/src/connection/runtime.ts @@ -0,0 +1,16 @@ +import { connectionLayer as clientConnectionLayer } from "@t3tools/client-runtime/connection"; +import * as Layer from "effect/Layer"; +import { Atom } from "effect/unstable/reactivity"; + +import { runtimeContextLayer } from "../lib/runtime"; +import { connectionPlatformLayer } from "./platform"; + +const providedConnectionPlatformLayer = connectionPlatformLayer.pipe( + Layer.provide(runtimeContextLayer), +); + +export const connectionLayer = clientConnectionLayer.pipe( + Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)), +); + +export const connectionAtomRuntime = Atom.runtime(connectionLayer); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts new file mode 100644 index 00000000000..b7030f4c468 --- /dev/null +++ b/apps/web/src/connection/storage.ts @@ -0,0 +1,504 @@ +import { + ConnectionCatalogDocument, + type ConnectionCatalogDocument as ConnectionCatalogDocumentType, + ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EMPTY_CONNECTION_CATALOG_DOCUMENT, + EnvironmentCacheStore, + registerConnectionInCatalog, + removeCatalogValue, + removeConnectionFromCatalog, + replaceCatalogValue, +} from "@t3tools/client-runtime/platform"; +import { RemoteDpopAccessTokenStore } from "@t3tools/client-runtime/authorization"; +import { + ConnectionCredentialStore, + ConnectionProfileStore, + ConnectionTransientError, +} from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + OrchestrationThread, + OrchestrationShellSnapshot, + ThreadId, +} from "@t3tools/contracts"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +const DATABASE_NAME = "t3code:connection-runtime"; +const DATABASE_VERSION = 2; +const CATALOG_STORE_NAME = "catalog"; +const SHELL_STORE_NAME = "shell"; +const THREAD_STORE_NAME = "thread"; +const CATALOG_KEY = "document"; +const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; + +const StoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, +}); +const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); +const StoredThreadSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + threadId: ThreadId, + thread: OrchestrationThread, +}); +const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); +const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshotJson); +const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); +const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); + +function catalogError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: `Could not ${operation} the local connection catalog: ${String(cause)}`, + }); +} + +function persistenceError( + operation: + | "list-targets" + | "register-connection" + | "remove-connection" + | "load-shell" + | "save-shell" + | "load-thread" + | "save-thread" + | "remove-thread" + | "clear-environment", + cause: unknown, +) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () { + return yield* Effect.callback((resume) => { + if (typeof indexedDB === "undefined") { + resume( + Effect.fail(catalogError("open", "IndexedDB is unavailable in this browser context.")), + ); + return; + } + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (!request.result.objectStoreNames.contains(CATALOG_STORE_NAME)) { + request.result.createObjectStore(CATALOG_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(SHELL_STORE_NAME)) { + request.result.createObjectStore(SHELL_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { + request.result.createObjectStore(THREAD_STORE_NAME); + } + }); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }); +}); + +function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); + request.addEventListener("error", () => { + resume(Effect.fail(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); +} + +function writeDatabaseValue( + database: IDBDatabase, + storeName: string, + key: IDBValidKey, + value: unknown, +) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).put(value, key); + }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); +} + +function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).delete(key); + }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValue")); +} + +function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", transaction.error ?? "Unknown IndexedDB remove error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + const request = transaction.objectStore(storeName).openCursor(range); + request.addEventListener("error", () => { + resume( + Effect.fail(catalogError("remove", request.error ?? "Unknown IndexedDB cursor error")), + ); + }); + request.addEventListener("success", () => { + const cursor = request.result; + if (cursor === null) { + return; + } + cursor.delete(); + cursor.continue(); + }); + }).pipe(Effect.withSpan("web.connectionStorage.removeDatabaseValuesInRange")); +} + +function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { + return `${environmentId}:${threadId}`; +} + +const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { + return yield* decodeConnectionCatalogDocument(raw).pipe( + Effect.mapError((cause) => catalogError("decode", cause)), + ); +}); + +const encodeCatalog = Effect.fn("web.connectionStorage.encodeCatalog")(function* ( + catalog: ConnectionCatalogDocumentType, +) { + return yield* encodeConnectionCatalogDocument(catalog).pipe( + Effect.mapError((cause) => catalogError("encode", cause)), + ); +}); + +interface CatalogBackend { + readonly read: Effect.Effect; + readonly write: (raw: string) => Effect.Effect; +} + +function makeCatalogBackend(database: IDBDatabase): CatalogBackend { + const bridge = window.desktopBridge; + if (bridge?.getConnectionCatalog !== undefined && bridge.setConnectionCatalog !== undefined) { + return { + read: Effect.tryPromise({ + try: () => bridge.getConnectionCatalog!(), + catch: (cause) => catalogError("load", cause), + }), + write: (raw) => + Effect.tryPromise({ + try: () => bridge.setConnectionCatalog!(raw), + catch: (cause) => catalogError("save", cause), + }).pipe( + Effect.flatMap((stored) => + stored + ? Effect.void + : Effect.logWarning( + "Desktop secure storage is unavailable; connection changes will be kept only for this session.", + ), + ), + ), + }; + } + + return { + read: readDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY).pipe( + Effect.map((value) => (typeof value === "string" ? value : null)), + ), + write: (raw) => writeDatabaseValue(database, CATALOG_STORE_NAME, CATALOG_KEY, raw), + }; +} + +interface CatalogStore { + readonly read: Effect.Effect; + readonly update: ( + transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, + ) => Effect.Effect; +} + +const makeCatalogStore = Effect.fn("web.connectionStorage.makeCatalogStore")(function* ( + backend: CatalogBackend, +) { + const state = yield* Ref.make>(Option.none()); + const lock = yield* Semaphore.make(1); + + const loadUnlocked = Effect.fn("web.connectionStorage.loadCatalog")(function* () { + const cached = yield* Ref.get(state); + if (Option.isSome(cached)) { + return cached.value; + } + const raw = yield* backend.read; + const catalog = + raw === null || raw.trim() === "" + ? EMPTY_CONNECTION_CATALOG_DOCUMENT + : yield* decodeCatalog(raw); + yield* Ref.set(state, Option.some(catalog)); + return catalog; + }); + + const read = lock.withPermits(1)(loadUnlocked()); + const update: CatalogStore["update"] = Effect.fn("web.connectionStorage.updateCatalog")( + function* (transform) { + yield* lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* loadUnlocked()); + yield* backend.write(yield* encodeCatalog(next)); + yield* Ref.set(state, Option.some(next)); + }), + ); + }, + ); + + return { read, update } satisfies CatalogStore; +}); + +export const connectionStorageLayer = Layer.effectContext( + Effect.gen(function* () { + const database = yield* Effect.acquireRelease(openDatabase(), (database) => + Effect.sync(() => database.close()), + ); + const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); + + const targetStore = ConnectionTargetStore.of({ + list: catalog.read.pipe( + Effect.map((document) => document.targets), + Effect.mapError((cause) => persistenceError("list-targets", cause)), + ), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + catalog + .update((document) => registerConnectionInCatalog(document, registration)) + .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), + remove: (target) => + catalog + .update((document) => removeConnectionFromCatalog(document, target)) + .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), + }); + const profileStore = ConnectionProfileStore.of({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.profiles.find((profile) => profile.connectionId === connectionId), + ), + ), + ), + put: (profile) => + catalog.update((document) => ({ + ...document, + profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + profiles: removeCatalogValue( + document.profiles, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const credentialStore = ConnectionCredentialStore.of({ + get: (connectionId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.credentials.find((entry) => entry.connectionId === connectionId)?.credential, + ), + ), + ), + put: (connectionId, credential) => + catalog.update((document) => ({ + ...document, + credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, { + connectionId, + credential, + }), + })), + remove: (connectionId) => + catalog.update((document) => ({ + ...document, + credentials: removeCatalogValue( + document.credentials, + (value) => value.connectionId, + connectionId, + ), + })), + }); + const remoteTokenStore = RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + catalog.read.pipe( + Effect.map((document) => + Option.fromUndefinedOr( + document.remoteDpopTokens.find((token) => token.environmentId === environmentId), + ), + ), + ), + put: (token) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: replaceCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + token, + ), + })), + remove: (environmentId) => + catalog.update((document) => ({ + ...document, + remoteDpopTokens: removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + environmentId, + ), + })), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredShellSnapshot(raw).pipe( + Effect.mapError((cause) => persistenceError("load-shell", cause)), + Effect.map((stored) => + stored.environmentId === environmentId + ? Option.some(stored.snapshot) + : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-shell", cause), + ), + ), + saveShell: (environmentId, snapshot) => + Effect.gen(function* () { + const encoded = yield* encodeStoredShellSnapshot({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-shell", cause), + ), + ), + loadThread: (environmentId, threadId) => + readDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, threadId), + ).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredThreadSnapshot(raw).pipe( + Effect.mapError((cause) => persistenceError("load-thread", cause)), + Effect.map((stored) => + stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.thread) + : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-thread", cause), + ), + ), + saveThread: (environmentId, thread) => + Effect.gen(function* () { + const encoded = yield* encodeStoredThreadSnapshot({ + schemaVersion: 1, + environmentId, + threadId: thread.id, + thread, + }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); + yield* writeDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, thread.id), + encoded, + ); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-thread", cause), + ), + ), + removeThread: (environmentId, threadId) => + removeDatabaseValue( + database, + THREAD_STORE_NAME, + threadCacheKey(environmentId, threadId), + ).pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))), + clear: (environmentId) => + Effect.all( + [ + removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), + removeDatabaseValuesInRange( + database, + THREAD_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), + ], + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), + }); + + return Context.make(ConnectionTargetStore, targetStore).pipe( + Context.add(ConnectionRegistrationStore, registrationStore), + Context.add(ConnectionProfileStore, profileStore), + Context.add(ConnectionCredentialStore, credentialStore), + Context.add(RemoteDpopAccessTokenStore, remoteTokenStore), + Context.add(EnvironmentCacheStore, cacheStore), + ); + }), +); diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 38c59115a55..fed50394c90 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,6 +1,8 @@ -import { EDITORS, EditorId, LocalApi } from "@t3tools/contracts"; +import { EDITORS, EditorId, type EnvironmentId } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; +import { shellEnvironment } from "./state/shell"; const LAST_EDITOR_KEY = "t3code:last-editor"; @@ -26,10 +28,30 @@ export function resolveAndPersistPreferredEditor( return editor ?? null; } -export async function openInPreferredEditor(api: LocalApi, targetPath: string): Promise { - const { availableEditors } = await api.server.getConfig(); - const editor = resolveAndPersistPreferredEditor(availableEditors); - if (!editor) throw new Error("No available editors found."); - await api.shell.openInEditor(targetPath, editor); - return editor; +export function useOpenInPreferredEditor( + environmentId: EnvironmentId | null, + availableEditors: readonly EditorId[], +) { + const openInEditor = useAtomSet(shellEnvironment.openInEditor, { mode: "promise" }); + + return useCallback( + async (targetPath: string): Promise => { + if (environmentId === null) { + throw new Error("No environment is selected."); + } + const editor = resolveAndPersistPreferredEditor(availableEditors); + if (!editor) { + throw new Error("No available editors found."); + } + await openInEditor({ + environmentId, + input: { + cwd: targetPath, + editor, + }, + }); + return editor; + }, + [availableEditors, environmentId, openInEditor], + ); } diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts deleted file mode 100644 index bdb2e793069..00000000000 --- a/apps/web/src/environmentApi.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { EnvironmentId, EnvironmentApi } from "@t3tools/contracts"; - -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { readEnvironmentConnection } from "./environments/runtime"; - -const environmentApiOverridesForTests = new Map(); - -export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { - return { - terminal: { - open: (input) => rpcClient.terminal.open(input as never), - attach: (input, callback, options) => - rpcClient.terminal.attach(input as never, callback, options), - write: (input) => rpcClient.terminal.write(input as never), - resize: (input) => rpcClient.terminal.resize(input as never), - clear: (input) => rpcClient.terminal.clear(input as never), - restart: (input) => rpcClient.terminal.restart(input as never), - close: (input) => rpcClient.terminal.close(input as never), - onMetadata: (callback, options) => rpcClient.terminal.onMetadata(callback, options), - }, - projects: { - searchEntries: rpcClient.projects.searchEntries, - writeFile: rpcClient.projects.writeFile, - }, - filesystem: { - browse: rpcClient.filesystem.browse, - }, - sourceControl: { - lookupRepository: rpcClient.sourceControl.lookupRepository, - cloneRepository: rpcClient.sourceControl.cloneRepository, - publishRepository: rpcClient.sourceControl.publishRepository, - }, - vcs: { - pull: rpcClient.vcs.pull, - refreshStatus: rpcClient.vcs.refreshStatus, - onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), - listRefs: rpcClient.vcs.listRefs, - createWorktree: rpcClient.vcs.createWorktree, - removeWorktree: rpcClient.vcs.removeWorktree, - createRef: rpcClient.vcs.createRef, - switchRef: rpcClient.vcs.switchRef, - init: rpcClient.vcs.init, - }, - git: { - resolvePullRequest: rpcClient.git.resolvePullRequest, - preparePullRequestThread: rpcClient.git.preparePullRequestThread, - }, - review: { - getDiffPreview: rpcClient.review.getDiffPreview, - }, - orchestration: { - dispatchCommand: rpcClient.orchestration.dispatchCommand, - getTurnDiff: rpcClient.orchestration.getTurnDiff, - getFullThreadDiff: rpcClient.orchestration.getFullThreadDiff, - getArchivedShellSnapshot: rpcClient.orchestration.getArchivedShellSnapshot, - subscribeShell: (callback, options) => - rpcClient.orchestration.subscribeShell(callback, options), - subscribeThread: (input, callback, options) => - rpcClient.orchestration.subscribeThread(input, callback, options), - }, - }; -} - -export function readEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi | undefined { - if (typeof window === "undefined") { - return undefined; - } - - if (!environmentId) { - return undefined; - } - - const overriddenApi = environmentApiOverridesForTests.get(environmentId); - if (overriddenApi) { - return overriddenApi; - } - - const connection = readEnvironmentConnection(environmentId); - return connection ? createEnvironmentApi(connection.client) : undefined; -} - -export function ensureEnvironmentApi(environmentId: EnvironmentId): EnvironmentApi { - const api = readEnvironmentApi(environmentId); - if (!api) { - throw new Error(`Environment API not found for environment ${environmentId}`); - } - return api; -} - -export function __setEnvironmentApiOverrideForTests( - environmentId: EnvironmentId, - api: EnvironmentApi, -): void { - environmentApiOverridesForTests.set(environmentId, api); -} - -export function __resetEnvironmentApiOverridesForTests(): void { - environmentApiOverridesForTests.clear(); -} diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index ae879c671f5..c66bf4977b2 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -1,54 +1,40 @@ -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - selectProjectsAcrossEnvironments, - selectSidebarThreadsAcrossEnvironments, - selectSidebarThreadsForProjectRef, - selectSidebarThreadsForProjectRefs, - type AppState, - type EnvironmentState, -} from "./store"; import { deriveLogicalProjectKey, deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, resolveProjectGroupingMode, } from "./logicalProject"; -import type { Project, SidebarThreadSummary } from "./types"; -import { DEFAULT_INTERACTION_MODE } from "./types"; - -// ── Fixture Identifiers ────────────────────────────────────────────── - -const primaryEnvId = EnvironmentId.make("env-primary"); -const remoteEnvId = EnvironmentId.make("env-remote"); - -const sharedProjectPrimaryId = ProjectId.make("shared-proj-primary"); -const sharedProjectRemoteId = ProjectId.make("shared-proj-remote"); -const localOnlyProjectId = ProjectId.make("local-only-proj"); -const remoteOnlyProjectId = ProjectId.make("remote-only-proj"); - -const threadP1 = ThreadId.make("thread-shared-primary-1"); -const threadP2 = ThreadId.make("thread-shared-primary-2"); -const threadR1 = ThreadId.make("thread-shared-remote-1"); -const threadL1 = ThreadId.make("thread-local-only-1"); -const threadRO1 = ThreadId.make("thread-remote-only-1"); - -const SHARED_REPO_CANONICAL_KEY = "github.com/example/shared-repo"; -const DEFAULT_GROUPING_SETTINGS = { +import type { Project } from "./types"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); +const repositoryIdentity = { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, +}; +const defaultGroupingSettings = { sidebarProjectGroupingMode: "repository" as const, sidebarProjectGroupingOverrides: {}, }; -// ── Factory Helpers ────────────────────────────────────────────────── - -function makeProject( - overrides: Partial & Pick, -): Project { +function makeProject(overrides: Partial = {}): Project { return { - cwd: `/tmp/${overrides.name}`, - defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + id: ProjectId.make("project-1"), + environmentId: primaryEnvironmentId, + title: "shared-repo", + workspaceRoot: "/tmp/shared-repo", + repositoryIdentity: null, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", scripts: [], @@ -56,559 +42,81 @@ function makeProject( }; } -function makeSidebarThreadSummary( - overrides: Partial & - Pick, -): SidebarThreadSummary { - return { - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - createdAt: "2026-01-01T00:00:00.000Z", - archivedAt: null, - updatedAt: "2026-01-01T00:00:00.000Z", - latestTurn: null, - branch: null, - worktreePath: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - ...overrides, - }; -} - -function makeEmptyEnvironmentState(): EnvironmentState { - return { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; -} - -// ── Fixture: Two environments, shared + local-only + remote-only projects ── - -function makeFixtureState(): AppState { - // Shared project: same repo in both envs - const sharedProjectPrimary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const sharedProjectRemote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - // Local-only project - const localOnlyProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - // Remote-only project - const remoteOnlyProject = makeProject({ - id: remoteOnlyProjectId, - environmentId: remoteEnvId, - name: "remote-only", - }); - - // Threads - const summaryP1 = makeSidebarThreadSummary({ - id: threadP1, - environmentId: primaryEnvId, - projectId: sharedProjectPrimaryId, - title: "Shared primary thread 1", - }); - const summaryP2 = makeSidebarThreadSummary({ - id: threadP2, - environmentId: primaryEnvId, - projectId: sharedProjectPrimaryId, - title: "Shared primary thread 2", - }); - const summaryR1 = makeSidebarThreadSummary({ - id: threadR1, - environmentId: remoteEnvId, - projectId: sharedProjectRemoteId, - title: "Shared remote thread 1", - }); - const summaryL1 = makeSidebarThreadSummary({ - id: threadL1, - environmentId: primaryEnvId, - projectId: localOnlyProjectId, - title: "Local only thread 1", - }); - const summaryRO1 = makeSidebarThreadSummary({ - id: threadRO1, - environmentId: remoteEnvId, - projectId: remoteOnlyProjectId, - title: "Remote only thread 1", - }); - - const primaryEnvState: EnvironmentState = { - ...makeEmptyEnvironmentState(), - projectIds: [sharedProjectPrimaryId, localOnlyProjectId], - projectById: { - [sharedProjectPrimaryId]: sharedProjectPrimary, - [localOnlyProjectId]: localOnlyProject, - }, - threadIds: [threadP1, threadP2, threadL1], - threadIdsByProjectId: { - [sharedProjectPrimaryId]: [threadP1, threadP2], - [localOnlyProjectId]: [threadL1], - }, - sidebarThreadSummaryById: { - [threadP1]: summaryP1, - [threadP2]: summaryP2, - [threadL1]: summaryL1, - }, - }; - - const remoteEnvState: EnvironmentState = { - ...makeEmptyEnvironmentState(), - projectIds: [sharedProjectRemoteId, remoteOnlyProjectId], - projectById: { - [sharedProjectRemoteId]: sharedProjectRemote, - [remoteOnlyProjectId]: remoteOnlyProject, - }, - threadIds: [threadR1, threadRO1], - threadIdsByProjectId: { - [sharedProjectRemoteId]: [threadR1], - [remoteOnlyProjectId]: [threadRO1], - }, - sidebarThreadSummaryById: { - [threadR1]: summaryR1, - [threadRO1]: summaryRO1, - }, - }; - - return { - activeEnvironmentId: primaryEnvId, - environmentStateById: { - [primaryEnvId]: primaryEnvState, - [remoteEnvId]: remoteEnvState, - }, - }; -} - -// ── Tests ──────────────────────────────────────────────────────────── - describe("environment grouping", () => { - describe("deriveLogicalProjectKey", () => { - it("uses repositoryIdentity.canonicalKey when present", () => { - const project = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - expect(deriveLogicalProjectKey(project)).toBe(SHARED_REPO_CANONICAL_KEY); - }); - - it("falls back to scoped project key when no repositoryIdentity", () => { - const project = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - expect(deriveLogicalProjectKey(project)).toBe(derivePhysicalProjectKey(project)); - }); - - it("groups projects from different environments that share the same canonical key", () => { - const primary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const remote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - expect(deriveLogicalProjectKey(primary)).toBe(deriveLogicalProjectKey(remote)); - }); - - it("groups repo root and nested projects from the same repository by default", () => { - const rootProject = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - cwd: "/workspace/repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const nestedProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect(deriveLogicalProjectKey(rootProject)).toBe(SHARED_REPO_CANONICAL_KEY); - expect(deriveLogicalProjectKey(nestedProject)).toBe(SHARED_REPO_CANONICAL_KEY); - }); - - it("uses repository path grouping when requested", () => { - const rootProject = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - cwd: "/workspace/repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const nestedProject = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect( - deriveLogicalProjectKey(rootProject, { - groupingMode: "repository_path", - }), - ).toBe(SHARED_REPO_CANONICAL_KEY); - expect( - deriveLogicalProjectKey(nestedProject, { - groupingMode: "repository_path", - }), - ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); - }); - - it("groups matching nested project paths across environments when repo roots differ", () => { - const primary = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "web", - cwd: "/workspace/repo/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/workspace/repo", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - const remote = makeProject({ - id: sharedProjectRemoteId, - environmentId: remoteEnvId, - name: "web", - cwd: "/srv/checkout/apps/web", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - rootPath: "/srv/checkout", - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect( - deriveLogicalProjectKey(primary, { - groupingMode: "repository_path", - }), - ).toBe(`${SHARED_REPO_CANONICAL_KEY}::apps/web`); - expect( - deriveLogicalProjectKey(primary, { - groupingMode: "repository_path", - }), - ).toBe( - deriveLogicalProjectKey(remote, { - groupingMode: "repository_path", - }), - ); + it("groups matching repository identities across environments", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, }); - it("does NOT group projects without shared canonical key", () => { - const local = makeProject({ - id: localOnlyProjectId, - environmentId: primaryEnvId, - name: "local-only", - }); - const remote = makeProject({ - id: remoteOnlyProjectId, - environmentId: remoteEnvId, - name: "remote-only", - }); - expect(deriveLogicalProjectKey(local)).not.toBe(deriveLogicalProjectKey(remote)); - }); - - it("uses per-project overrides from settings", () => { - const project = makeProject({ - id: sharedProjectPrimaryId, - environmentId: primaryEnvId, - name: "shared-repo", - repositoryIdentity: { - canonicalKey: SHARED_REPO_CANONICAL_KEY, - locator: { - source: "git-remote", - remoteName: "origin", - remoteUrl: "https://github.com/example/shared-repo.git", - }, - }, - }); - - expect(resolveProjectGroupingMode(project, DEFAULT_GROUPING_SETTINGS)).toBe("repository"); - expect( - deriveLogicalProjectKeyFromSettings(project, { - ...DEFAULT_GROUPING_SETTINGS, - sidebarProjectGroupingOverrides: { - [derivePhysicalProjectKey(project)]: "separate", - }, - }), - ).toBe(derivePhysicalProjectKey(project)); - }); + expect(deriveLogicalProjectKey(primary)).toBe(repositoryIdentity.canonicalKey); + expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); }); - describe("selectProjectsAcrossEnvironments", () => { - it("returns all projects from all environments", () => { - const state = makeFixtureState(); - const projects = selectProjectsAcrossEnvironments(state); - expect(projects).toHaveLength(4); - const names = projects.map((p) => p.name).toSorted(); - expect(names).toEqual(["local-only", "remote-only", "shared-repo", "shared-repo"]); + it("keeps projects without repository identity physically scoped", () => { + const primary = makeProject(); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, }); - }); - describe("selectSidebarThreadsAcrossEnvironments", () => { - it("returns all sidebar thread summaries from all environments", () => { - const state = makeFixtureState(); - const threads = selectSidebarThreadsAcrossEnvironments(state); - expect(threads).toHaveLength(5); - const ids = new Set(threads.map((t) => t.id)); - expect(ids).toContain(threadP1); - expect(ids).toContain(threadP2); - expect(ids).toContain(threadR1); - expect(ids).toContain(threadL1); - expect(ids).toContain(threadRO1); - }); + expect(deriveLogicalProjectKey(primary)).toBe(derivePhysicalProjectKey(primary)); + expect(deriveLogicalProjectKey(remote)).toBe(derivePhysicalProjectKey(remote)); + expect(deriveLogicalProjectKey(primary)).not.toBe(deriveLogicalProjectKey(remote)); }); - describe("selectSidebarThreadsForProjectRef", () => { - it("returns threads for a single project ref", () => { - const state = makeFixtureState(); - const ref = scopeProjectRef(primaryEnvId, sharedProjectPrimaryId); - const threads = selectSidebarThreadsForProjectRef(state, ref); - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); - - it("returns empty array for null ref", () => { - const state = makeFixtureState(); - expect(selectSidebarThreadsForProjectRef(state, null)).toEqual([]); - }); + it("uses the physical key when repository grouping is disabled", () => { + const project = makeProject({ repositoryIdentity }); - it("returns empty array for nonexistent environment", () => { - const state = makeFixtureState(); - const ref = scopeProjectRef(EnvironmentId.make("nonexistent"), sharedProjectPrimaryId); - expect(selectSidebarThreadsForProjectRef(state, ref)).toEqual([]); - }); + expect( + deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: {}, + }), + ).toBe(derivePhysicalProjectKey(project)); }); - describe("selectSidebarThreadsForProjectRefs", () => { - it("returns empty for empty refs", () => { - const state = makeFixtureState(); - expect(selectSidebarThreadsForProjectRefs(state, [])).toEqual([]); - }); - - it("returns threads for a single ref", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(primaryEnvId, sharedProjectPrimaryId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); - - it("returns combined threads from multiple refs across environments", () => { - const state = makeFixtureState(); - const refs = [ - scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), - scopeProjectRef(remoteEnvId, sharedProjectRemoteId), - ]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(3); - const ids = new Set(threads.map((t) => t.id)); - expect(ids).toContain(threadP1); - expect(ids).toContain(threadP2); - expect(ids).toContain(threadR1); - }); - - it("returns threads from remote-only project", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(remoteEnvId, remoteOnlyProjectId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(1); - expect(threads[0]?.id).toBe(threadRO1); - }); - - it("returns threads from local-only project", () => { - const state = makeFixtureState(); - const refs = [scopeProjectRef(primaryEnvId, localOnlyProjectId)]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - expect(threads).toHaveLength(1); - expect(threads[0]?.id).toBe(threadL1); - }); + it("allows a per-project override to separate an otherwise grouped repository", () => { + const project = makeProject({ repositoryIdentity }); + const physicalKey = derivePhysicalProjectKey(project); - it("handles refs with nonexistent environment gracefully", () => { - const state = makeFixtureState(); - const refs = [ - scopeProjectRef(primaryEnvId, sharedProjectPrimaryId), - scopeProjectRef(EnvironmentId.make("nonexistent"), ProjectId.make("nope")), - ]; - const threads = selectSidebarThreadsForProjectRefs(state, refs); - // Only returns threads from the valid ref - expect(threads).toHaveLength(2); - expect(threads.map((t) => t.id)).toEqual([threadP1, threadP2]); - }); + expect( + deriveLogicalProjectKeyFromSettings(project, { + ...defaultGroupingSettings, + sidebarProjectGroupingOverrides: { + [physicalKey]: "separate", + }, + }), + ).toBe(physicalKey); }); - describe("logical project grouping for sidebar", () => { - it("computes correct logical key for grouped projects and aggregates threads", () => { - const state = makeFixtureState(); - const allProjects = selectProjectsAcrossEnvironments(state); - - // Group by logical key - const groups = new Map(); - for (const project of allProjects) { - const key = deriveLogicalProjectKey(project); - const existing = groups.get(key) ?? []; - existing.push(project); - groups.set(key, existing); - } - - // Shared project should be grouped - const sharedGroup = groups.get(SHARED_REPO_CANONICAL_KEY); - expect(sharedGroup).toBeDefined(); - expect(sharedGroup).toHaveLength(2); - expect(sharedGroup!.map((p) => p.environmentId).toSorted()).toEqual( - [primaryEnvId, remoteEnvId].toSorted(), - ); - - // Build member refs for the grouped project and fetch threads - const memberRefs = sharedGroup!.map((p) => scopeProjectRef(p.environmentId, p.id)); - const threads = selectSidebarThreadsForProjectRefs(state, memberRefs); - expect(threads).toHaveLength(3); - const threadIds = threads.map((t) => t.id); - expect(threadIds).toContain(threadP1); - expect(threadIds).toContain(threadP2); - expect(threadIds).toContain(threadR1); - }); + it("allows a per-project override to group a repository while the global mode is separate", () => { + const project = makeProject({ repositoryIdentity }); - it("local-only and remote-only projects remain ungrouped", () => { - const state = makeFixtureState(); - const allProjects = selectProjectsAcrossEnvironments(state); - - const groups = new Map(); - for (const project of allProjects) { - const key = deriveLogicalProjectKey(project); - const existing = groups.get(key) ?? []; - existing.push(project); - groups.set(key, existing); - } - - // Should have 3 groups total: shared, local-only, remote-only - expect(groups.size).toBe(3); + expect( + deriveLogicalProjectKeyFromSettings(project, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: { + [derivePhysicalProjectKey(project)]: "repository", + }, + }), + ).toBe(repositoryIdentity.canonicalKey); + }); - // Local-only group - const localKey = deriveLogicalProjectKey( - allProjects.find((p) => p.id === localOnlyProjectId)!, - ); - expect(groups.get(localKey)).toHaveLength(1); + it("reports the effective grouping mode after applying an override", () => { + const project = makeProject({ repositoryIdentity }); + const physicalKey = derivePhysicalProjectKey(project); - // Remote-only group - const remoteKey = deriveLogicalProjectKey( - allProjects.find((p) => p.id === remoteOnlyProjectId)!, - ); - expect(groups.get(remoteKey)).toHaveLength(1); - }); + expect(resolveProjectGroupingMode(project, defaultGroupingSettings)).toBe("repository"); + expect( + resolveProjectGroupingMode(project, { + ...defaultGroupingSettings, + sidebarProjectGroupingOverrides: { + [physicalKey]: "separate", + }, + }), + ).toBe("separate"); }); }); diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index db4406ecee0..cfe5c3a8da8 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -2,7 +2,7 @@ import { attachEnvironmentDescriptor, createKnownEnvironment, type KnownEnvironment, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { HttpClientError } from "effect/unstable/http"; diff --git a/apps/web/src/environments/primary/httpClient.ts b/apps/web/src/environments/primary/httpClient.ts index d9404b4b8d3..d5cb22433c4 100644 --- a/apps/web/src/environments/primary/httpClient.ts +++ b/apps/web/src/environments/primary/httpClient.ts @@ -1,4 +1,4 @@ -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index 04b7d903d4b..c62907d3572 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -1,9 +1,11 @@ import type { DesktopEnvironmentBootstrap } from "@t3tools/contracts"; -import type { KnownEnvironment } from "@t3tools/client-runtime"; export interface PrimaryEnvironmentTarget { - readonly source: KnownEnvironment["source"]; - readonly target: KnownEnvironment["target"]; + readonly source: "configured" | "window-origin" | "desktop-managed"; + readonly target: { + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + }; } const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts deleted file mode 100644 index f6c5fc85277..00000000000 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { - EnvironmentId, - type LocalApi, - type PersistedSavedEnvironmentRecord, -} from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - readSavedEnvironmentCredential, - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentCredential, -} from "./catalog"; - -let resolveRegistryRead: () => void = () => { - throw new Error("Registry read resolver was not initialized."); -}; - -describe("environment runtime catalog stores", () => { - beforeEach(async () => { - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - }); - - afterEach(async () => { - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - vi.unstubAllGlobals(); - }); - - it("resets the saved environment registry store state", () => { - const environmentId = EnvironmentId.make("environment-1"); - - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId, - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }); - - expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toBeDefined(); - - resetSavedEnvironmentRegistryStoreForTests(); - - expect(useSavedEnvironmentRegistryStore.getState().byId).toEqual({}); - }); - - it("resets the saved environment runtime store state", () => { - const environmentId = EnvironmentId.make("environment-1"); - - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connected", - connectedAt: "2026-04-09T00:00:00.000Z", - }); - - expect(useSavedEnvironmentRuntimeStore.getState().byId[environmentId]).toBeDefined(); - - resetSavedEnvironmentRuntimeStoreForTests(); - - expect(useSavedEnvironmentRuntimeStore.getState().byId).toEqual({}); - }); - - it("decodes legacy bearer secrets and writes versioned DPoP credentials", async () => { - let storedSecret: string | null = "legacy-bearer-token"; - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => storedSecret, - setSavedEnvironmentSecret: async (_environmentId, secret) => { - storedSecret = secret; - return true; - }, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - const environmentId = EnvironmentId.make("environment-1"); - - await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ - version: 1, - method: "bearer", - token: "legacy-bearer-token", - }); - await expect( - writeSavedEnvironmentCredential(environmentId, { - version: 1, - method: "dpop", - accessToken: "managed-dpop-access-token", - }), - ).resolves.toBe(true); - await expect(readSavedEnvironmentCredential(environmentId)).resolves.toEqual({ - version: 1, - method: "dpop", - accessToken: "managed-dpop-access-token", - }); - }); - - it("does not throw when local api lookup fails during registry persistence", async () => { - vi.unstubAllGlobals(); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - - expect(() => - useSavedEnvironmentRegistryStore.getState().upsert({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }), - ).not.toThrow(); - - expect(errorSpy).toHaveBeenCalledWith("[SAVED_ENVIRONMENTS] persist failed", expect.any(Error)); - }); - - it("does not let stale hydration overwrite records added while hydration is in flight", async () => { - resolveRegistryRead = () => { - throw new Error("Registry read resolver was not initialized."); - }; - - vi.stubGlobal("window", { - nativeApi: { - persistence: { - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: () => - new Promise((resolve) => { - resolveRegistryRead = () => resolve([]); - }), - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - }, - } satisfies Pick, - }); - - const { __resetLocalApiForTests } = await import("../../localApi"); - await __resetLocalApiForTests(); - - const hydrationPromise = waitForSavedEnvironmentRegistryHydration(); - - const environmentId = EnvironmentId.make("environment-1"); - const record = { - environmentId, - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - } as const; - - useSavedEnvironmentRegistryStore.getState().upsert(record); - - resolveRegistryRead(); - await hydrationPromise; - - expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record); - }); -}); diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts deleted file mode 100644 index 570d9753c13..00000000000 --- a/apps/web/src/environments/runtime/catalog.ts +++ /dev/null @@ -1,410 +0,0 @@ -import { getKnownEnvironmentHttpBaseUrl } from "@t3tools/client-runtime"; -import type { - AuthEnvironmentScope, - EnvironmentId, - ExecutionEnvironmentDescriptor, - PersistedSavedEnvironmentRecord, - ServerConfig, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { create } from "zustand"; - -import { ensureLocalApi } from "../../localApi"; -import { getPrimaryKnownEnvironment } from "../primary"; - -export interface SavedEnvironmentRecord { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly createdAt: string; - readonly lastConnectedAt: string | null; - readonly desktopSsh?: PersistedSavedEnvironmentRecord["desktopSsh"]; - readonly relayManaged?: PersistedSavedEnvironmentRecord["relayManaged"]; -} - -export const SavedEnvironmentCredential = Schema.Union([ - Schema.Struct({ - version: Schema.Literal(1), - method: Schema.Literal("bearer"), - token: Schema.String, - }), - Schema.Struct({ - version: Schema.Literal(1), - method: Schema.Literal("dpop"), - accessToken: Schema.String, - }), -]); -export type SavedEnvironmentCredential = typeof SavedEnvironmentCredential.Type; - -const SavedEnvironmentCredentialJson = Schema.fromJsonString(SavedEnvironmentCredential); -const decodeSavedEnvironmentCredentialJson = Schema.decodeUnknownOption( - SavedEnvironmentCredentialJson, -); -const encodeSavedEnvironmentCredentialJson = Schema.encodeSync(SavedEnvironmentCredentialJson); - -interface SavedEnvironmentRegistryState { - readonly byId: Record; -} - -interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { - readonly upsert: (record: SavedEnvironmentRecord) => void; - readonly remove: (environmentId: EnvironmentId) => void; - readonly markConnected: (environmentId: EnvironmentId, connectedAt: string) => void; - readonly rename: (environmentId: EnvironmentId, label: string) => void; - readonly reset: () => void; -} - -let savedEnvironmentRegistryHydrated = false; -let savedEnvironmentRegistryHydrationPromise: Promise | null = null; - -export function toPersistedSavedEnvironmentRecord( - record: SavedEnvironmentRecord, -): PersistedSavedEnvironmentRecord { - return { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - }; -} - -function valuesOfSavedEnvironmentRegistry( - byId: Record, -): ReadonlyArray { - return Object.values(byId) as ReadonlyArray; -} - -function persistSavedEnvironmentRegistryState( - byId: Record, -): void { - try { - void ensureLocalApi() - .persistence.setSavedEnvironmentRegistry( - valuesOfSavedEnvironmentRegistry(byId).map((record) => - toPersistedSavedEnvironmentRecord(record), - ), - ) - .catch((error) => { - console.error("[SAVED_ENVIRONMENTS] persist failed", error); - }); - } catch (error) { - console.error("[SAVED_ENVIRONMENTS] persist failed", error); - } -} - -function replaceSavedEnvironmentRegistryState( - records: ReadonlyArray, -): void { - const currentById = useSavedEnvironmentRegistryStore.getState().byId; - const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record])); - useSavedEnvironmentRegistryStore.setState({ - byId: { - ...hydratedById, - ...currentById, - }, - }); -} - -async function hydrateSavedEnvironmentRegistry(): Promise { - if (savedEnvironmentRegistryHydrated) { - return; - } - if (savedEnvironmentRegistryHydrationPromise) { - return savedEnvironmentRegistryHydrationPromise; - } - - const nextHydration = (async () => { - try { - const persistedRecords = await ensureLocalApi().persistence.getSavedEnvironmentRegistry(); - replaceSavedEnvironmentRegistryState(persistedRecords); - } catch (error) { - console.error("[SAVED_ENVIRONMENTS] hydrate failed", error); - } finally { - savedEnvironmentRegistryHydrated = true; - } - })(); - - const hydrationPromise = nextHydration.finally(() => { - if (savedEnvironmentRegistryHydrationPromise === hydrationPromise) { - savedEnvironmentRegistryHydrationPromise = null; - } - }); - savedEnvironmentRegistryHydrationPromise = hydrationPromise; - - return savedEnvironmentRegistryHydrationPromise; -} - -export const useSavedEnvironmentRegistryStore = create()((set) => ({ - byId: {}, - upsert: (record) => - set((state) => { - const byId = { - ...state.byId, - [record.environmentId]: record, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - remove: (environmentId) => - set((state) => { - const { [environmentId]: _removed, ...remaining } = state.byId; - persistSavedEnvironmentRegistryState(remaining); - return { - byId: remaining, - }; - }), - markConnected: (environmentId, connectedAt) => - set((state) => { - const existing = state.byId[environmentId]; - if (!existing) { - return state; - } - const byId = { - ...state.byId, - [environmentId]: { - ...existing, - lastConnectedAt: connectedAt, - }, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - rename: (environmentId, label) => - set((state) => { - const existing = state.byId[environmentId]; - const nextLabel = label.trim(); - if (!existing || nextLabel.length === 0 || existing.label === nextLabel) { - return state; - } - const byId = { - ...state.byId, - [environmentId]: { - ...existing, - label: nextLabel, - }, - }; - persistSavedEnvironmentRegistryState(byId); - return { byId }; - }), - reset: () => { - persistSavedEnvironmentRegistryState({}); - set({ - byId: {}, - }); - }, -})); - -export function hasSavedEnvironmentRegistryHydrated(): boolean { - return savedEnvironmentRegistryHydrated; -} - -export function waitForSavedEnvironmentRegistryHydration(): Promise { - if (hasSavedEnvironmentRegistryHydrated()) { - return Promise.resolve(); - } - - return hydrateSavedEnvironmentRegistry(); -} - -export function listSavedEnvironmentRecords(): ReadonlyArray { - return Object.values(useSavedEnvironmentRegistryStore.getState().byId).toSorted((left, right) => - left.label.localeCompare(right.label), - ); -} - -export function getSavedEnvironmentRecord( - environmentId: EnvironmentId, -): SavedEnvironmentRecord | null { - return useSavedEnvironmentRegistryStore.getState().byId[environmentId] ?? null; -} - -export function getEnvironmentHttpBaseUrl(environmentId: EnvironmentId): string | null { - const primaryEnvironment = getPrimaryKnownEnvironment(); - if (primaryEnvironment?.environmentId === environmentId) { - return getKnownEnvironmentHttpBaseUrl(primaryEnvironment); - } - - return getSavedEnvironmentRecord(environmentId)?.httpBaseUrl ?? null; -} - -export function resolveEnvironmentHttpUrl(input: { - readonly environmentId: EnvironmentId; - readonly pathname: string; - readonly searchParams?: Record; -}): string { - const httpBaseUrl = getEnvironmentHttpBaseUrl(input.environmentId); - if (!httpBaseUrl) { - throw new Error(`Unable to resolve HTTP base URL for environment ${input.environmentId}.`); - } - - const url = new URL(httpBaseUrl); - url.pathname = input.pathname; - if (input.searchParams) { - url.search = new URLSearchParams(input.searchParams).toString(); - } - return url.toString(); -} - -export function resetSavedEnvironmentRegistryStoreForTests() { - savedEnvironmentRegistryHydrated = false; - savedEnvironmentRegistryHydrationPromise = null; - useSavedEnvironmentRegistryStore.setState({ byId: {} }); -} - -export async function persistSavedEnvironmentRecord(record: SavedEnvironmentRecord): Promise { - const byId = { - ...useSavedEnvironmentRegistryStore.getState().byId, - [record.environmentId]: record, - }; - - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - valuesOfSavedEnvironmentRegistry(byId).map((entry) => toPersistedSavedEnvironmentRecord(entry)), - ); -} - -export async function readSavedEnvironmentBearerToken( - environmentId: EnvironmentId, -): Promise { - return ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); -} - -export async function readSavedEnvironmentCredential( - environmentId: EnvironmentId, -): Promise { - const secret = await ensureLocalApi().persistence.getSavedEnvironmentSecret(environmentId); - if (!secret) { - return null; - } - const decoded = decodeSavedEnvironmentCredentialJson(secret); - if (Option.isSome(decoded)) { - return decoded.value; - } - // Legacy bearer secrets were stored directly as strings. - return { version: 1, method: "bearer", token: secret }; -} - -export async function writeSavedEnvironmentCredential( - environmentId: EnvironmentId, - credential: SavedEnvironmentCredential, -): Promise { - return ensureLocalApi().persistence.setSavedEnvironmentSecret( - environmentId, - encodeSavedEnvironmentCredentialJson(credential), - ); -} - -export async function writeSavedEnvironmentBearerToken( - environmentId: EnvironmentId, - bearerToken: string, -): Promise { - return ensureLocalApi().persistence.setSavedEnvironmentSecret(environmentId, bearerToken); -} - -export async function removeSavedEnvironmentBearerToken( - environmentId: EnvironmentId, -): Promise { - await ensureLocalApi().persistence.removeSavedEnvironmentSecret(environmentId); -} - -export type SavedEnvironmentConnectionState = "connecting" | "connected" | "disconnected" | "error"; - -export type SavedEnvironmentAuthState = "authenticated" | "requires-auth" | "unknown"; - -export interface SavedEnvironmentRuntimeState { - readonly connectionState: SavedEnvironmentConnectionState; - readonly authState: SavedEnvironmentAuthState; - readonly lastError: string | null; - readonly lastErrorAt: string | null; - readonly scopes: ReadonlyArray | null; - readonly descriptor: ExecutionEnvironmentDescriptor | null; - readonly serverConfig: ServerConfig | null; - readonly connectedAt: string | null; - readonly disconnectedAt: string | null; -} - -interface SavedEnvironmentRuntimeStoreState { - readonly byId: Record; - readonly ensure: (environmentId: EnvironmentId) => void; - readonly patch: ( - environmentId: EnvironmentId, - patch: Partial, - ) => void; - readonly clear: (environmentId: EnvironmentId) => void; - readonly reset: () => void; -} - -const DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE: SavedEnvironmentRuntimeState = Object.freeze({ - connectionState: "disconnected", - authState: "unknown", - lastError: null, - lastErrorAt: null, - scopes: null, - descriptor: null, - serverConfig: null, - connectedAt: null, - disconnectedAt: null, -}); - -function createDefaultSavedEnvironmentRuntimeState(): SavedEnvironmentRuntimeState { - return { - ...DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE, - }; -} - -export const useSavedEnvironmentRuntimeStore = create()( - (set) => ({ - byId: {}, - ensure: (environmentId) => - set((state) => { - if (state.byId[environmentId]) { - return state; - } - return { - byId: { - ...state.byId, - [environmentId]: createDefaultSavedEnvironmentRuntimeState(), - }, - }; - }), - patch: (environmentId, patch) => - set((state) => ({ - byId: { - ...state.byId, - [environmentId]: { - ...(state.byId[environmentId] ?? createDefaultSavedEnvironmentRuntimeState()), - ...patch, - }, - }, - })), - clear: (environmentId) => - set((state) => { - const { [environmentId]: _removed, ...remaining } = state.byId; - return { - byId: remaining, - }; - }), - reset: () => - set({ - byId: {}, - }), - }), -); - -export function getSavedEnvironmentRuntimeState( - environmentId: EnvironmentId, -): SavedEnvironmentRuntimeState { - return ( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId] ?? - DEFAULT_SAVED_ENVIRONMENT_RUNTIME_STATE - ); -} - -export function resetSavedEnvironmentRuntimeStoreForTests() { - useSavedEnvironmentRuntimeStore.getState().reset(); -} diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts deleted file mode 100644 index 392db299339..00000000000 --- a/apps/web/src/environments/runtime/connection.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { createEnvironmentConnection } from "./connection"; -import type { WsRpcClient } from "@t3tools/client-runtime"; - -function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { - const lifecycleListeners = new Set<(event: any) => void>(); - const configListeners = new Set<(event: any) => void>(); - const shellListeners = new Set<(event: any) => void>(); - let shellResubscribe: (() => void) | undefined; - - const client = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => { - shellResubscribe?.(); - }), - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-1"), - }, - })), - subscribeConfig: vi.fn((listener: (event: any) => void) => { - configListeners.add(listener); - return () => configListeners.delete(listener); - }), - subscribeLifecycle: vi.fn((listener: (event: any) => void) => { - lifecycleListeners.add(listener); - return () => lifecycleListeners.delete(listener); - }), - subscribeAuthAccess: () => () => undefined, - refreshProviders: vi.fn(async () => undefined), - upsertKeybinding: vi.fn(async () => undefined), - getSettings: vi.fn(async () => undefined), - updateSettings: vi.fn(async () => undefined), - }, - orchestration: { - dispatchCommand: vi.fn(async () => undefined), - getTurnDiff: vi.fn(async () => undefined), - getFullThreadDiff: vi.fn(async () => undefined), - subscribeShell: vi.fn( - (listener: (event: any) => void, options?: { onResubscribe?: () => void }) => { - shellListeners.add(listener); - shellResubscribe = options?.onResubscribe; - if (config?.emitInitialSnapshot !== false) { - queueMicrotask(() => { - listener({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - projects: [], - threads: [], - updatedAt: "2026-04-12T00:00:00.000Z", - }, - }); - }); - } - return () => { - shellListeners.delete(listener); - if (shellResubscribe === options?.onResubscribe) { - shellResubscribe = undefined; - } - }; - }, - ), - subscribeThread: vi.fn(() => () => undefined), - }, - terminal: { - open: vi.fn(async () => undefined), - attach: vi.fn(() => () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - clear: vi.fn(async () => undefined), - restart: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - onEvent: vi.fn(() => () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - projects: { - searchEntries: vi.fn(async () => []), - writeFile: vi.fn(async () => undefined), - }, - shell: { - openInEditor: vi.fn(async () => undefined), - }, - git: { - runStackedAction: vi.fn(async () => ({}) as any), - resolvePullRequest: vi.fn(async () => undefined), - preparePullRequestThread: vi.fn(async () => undefined), - }, - review: { - getDiffPreview: vi.fn(async () => undefined), - }, - } as unknown as WsRpcClient; - - return { - client, - emitWelcome: (environmentId: EnvironmentId) => { - for (const listener of lifecycleListeners) { - listener({ - type: "welcome", - payload: { - environment: { - environmentId, - }, - }, - }); - } - }, - emitConfigSnapshot: (environmentId: EnvironmentId) => { - for (const listener of configListeners) { - listener({ - type: "snapshot", - config: { - environment: { - environmentId, - }, - }, - }); - } - }, - emitShellSnapshot: (snapshotSequence: number) => { - for (const listener of shellListeners) { - listener({ - kind: "snapshot", - snapshot: { - snapshotSequence, - projects: [], - threads: [], - updatedAt: "2026-04-12T00:00:00.000Z", - }, - }); - } - }, - }; -} - -describe("createEnvironmentConnection", () => { - it("bootstraps from the shell subscription snapshot", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient(); - const syncShellSnapshot = vi.fn(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot, - }); - - await connection.ensureBootstrapped(); - - expect(syncShellSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ snapshotSequence: 1 }), - environmentId, - ); - - await connection.dispose(); - }); - - it("rejects welcome/config identity drift", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client, emitWelcome } = createTestClient(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - }); - - expect(() => emitWelcome(EnvironmentId.make("env-2"))).toThrow( - "Environment connection env-1 changed identity to env-2 via server lifecycle welcome.", - ); - - await connection.dispose(); - }); - - it("waits for a fresh shell snapshot after reconnect", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client, emitShellSnapshot } = createTestClient(); - const syncShellSnapshot = vi.fn(); - - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot, - }); - - await connection.ensureBootstrapped(); - - const reconnectPromise = connection.reconnect(); - await Promise.resolve(); - expect(syncShellSnapshot).toHaveBeenCalledTimes(1); - - emitShellSnapshot(2); - await reconnectPromise; - - expect(client.reconnect).toHaveBeenCalledTimes(1); - expect(syncShellSnapshot).toHaveBeenCalledTimes(2); - expect(syncShellSnapshot).toHaveBeenLastCalledWith( - expect.objectContaining({ snapshotSequence: 2 }), - environmentId, - ); - - await connection.dispose(); - }); - - it("skips primary lifecycle/config subscriptions when no handlers are registered", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient(); - - const connection = createEnvironmentConnection({ - kind: "primary", - knownEnvironment: { - id: "env-1", - label: "Local env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - applyTerminalEvent: vi.fn(), - }); - - expect(client.server.subscribeLifecycle).not.toHaveBeenCalled(); - expect(client.server.subscribeConfig).not.toHaveBeenCalled(); - expect(client.orchestration.subscribeShell).toHaveBeenCalledOnce(); - - await connection.dispose(); - }); - - it("rejects bootstrap waits when a pending connection is disposed", async () => { - const environmentId = EnvironmentId.make("env-1"); - const { client } = createTestClient({ emitInitialSnapshot: false }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - id: "env-1", - label: "Remote env", - source: "manual", - target: { - httpBaseUrl: "http://example.test", - wsBaseUrl: "ws://example.test", - }, - environmentId, - }, - client, - applyShellEvent: vi.fn(), - syncShellSnapshot: vi.fn(), - }); - const pendingBootstrap = connection.ensureBootstrapped(); - - await connection.dispose(); - - await expect(pendingBootstrap).rejects.toThrow("was disposed before it finished bootstrapping"); - }); -}); diff --git a/apps/web/src/environments/runtime/connection.ts b/apps/web/src/environments/runtime/connection.ts deleted file mode 100644 index cb1c606b435..00000000000 --- a/apps/web/src/environments/runtime/connection.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - EnvironmentConnectionAttemptCancelledError, - EnvironmentConnectionDisposedError, - type EnvironmentConnection, -} from "@t3tools/client-runtime"; diff --git a/apps/web/src/environments/runtime/index.ts b/apps/web/src/environments/runtime/index.ts deleted file mode 100644 index 7333e03a42a..00000000000 --- a/apps/web/src/environments/runtime/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export { - getEnvironmentHttpBaseUrl, - getSavedEnvironmentRecord, - getSavedEnvironmentRuntimeState, - hasSavedEnvironmentRegistryHydrated, - listSavedEnvironmentRecords, - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, - resolveEnvironmentHttpUrl, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - type SavedEnvironmentRecord, - type SavedEnvironmentRuntimeState, -} from "./catalog"; - -export { - addSavedEnvironment, - addManagedRelayEnvironment, - connectDesktopSshEnvironment, - disconnectSavedEnvironment, - ensureEnvironmentConnectionBootstrapped, - getPrimaryEnvironmentConnection, - readEnvironmentConnection, - reconnectSavedEnvironment, - removeSavedEnvironment, - requireEnvironmentConnection, - resetEnvironmentServiceForTests, - startEnvironmentConnectionService, - subscribeEnvironmentConnections, - subscribeProviderInvalidations, -} from "./service"; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts deleted file mode 100644 index ab20617089f..00000000000 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ /dev/null @@ -1,1065 +0,0 @@ -import { EnvironmentAuthInvalidError, EnvironmentId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { Headers } from "effect/unstable/http"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const decodeEnvironmentAuthInvalidError = Schema.decodeUnknownSync(EnvironmentAuthInvalidError); - -let mockSavedRecords: Array> = []; - -const mockResolveRemotePairingTarget = vi.fn(); -const mockFetchRemoteEnvironmentDescriptor = vi.fn(); -const mockBootstrapRemoteBearerSession = vi.fn(); -const mockFetchRemoteSessionState = vi.fn(); -const mockFetchRemoteDpopSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); -let managedRelayDpopSigner: typeof import("@t3tools/client-runtime").ManagedRelayDpopSigner; -const mockRemoteHttpRunPromise = vi.fn((effect: Effect.Effect) => - Effect.runPromise( - effect.pipe( - Effect.provideService( - managedRelayDpopSigner, - managedRelayDpopSigner.of({ - thumbprint: Effect.succeed("thumbprint"), - createProof: () => Effect.succeed("dpop-proof"), - }), - ), - ), - ), -); -const mockBootstrapSshBearerSession = vi.fn(); -const mockFetchSshSessionState = vi.fn(); -const mockPersistSavedEnvironmentRecord = vi.fn(); -const mockWriteSavedEnvironmentBearerToken = vi.fn(); -const mockWriteSavedEnvironmentCredential = vi.fn(); -const mockSetSavedEnvironmentRegistry = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { - return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; -}); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockRemoveSavedEnvironmentBearerToken = vi.fn(); -const mockPatchRuntime = vi.fn(); -const mockClearRuntime = vi.fn(); -const mockRegistrySetState = vi.fn((next: { byId: Record> }) => { - mockSavedRecords = Object.values(next.byId); -}); -const mockRemove = vi.fn((environmentId: EnvironmentId) => { - mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); -}); -const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => { - mockSavedRecords = mockSavedRecords.map((record) => - record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record, - ); -}); -const mockRename = vi.fn((environmentId: EnvironmentId, label: string) => { - mockSavedRecords = mockSavedRecords.map((record) => - record.environmentId === environmentId ? { ...record, label } : record, - ); -}); -const mockUpsert = vi.fn((record: Record) => { - mockSavedRecords = [ - ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId), - record, - ]; -}); -const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords); -const mockEnsureSshEnvironment = vi.fn(); -const mockDisconnectSshEnvironment = vi.fn(); -const mockFetchSshEnvironmentDescriptor = vi.fn(); -const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record); -const mockCreateEnvironmentConnection = vi.fn(); -const mockClientGetConfig = vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }, -})); -const mockConnectManagedCloudEnvironment = vi.fn(); -const mockReadManagedRelayClerkToken = vi.fn(); - -vi.mock("@t3tools/shared/remote", async (importOriginal) => ({ - ...(await importOriginal()), - resolveRemotePairingTarget: mockResolveRemotePairingTarget, -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("../../cloud/linkEnvironment", () => ({ - connectManagedCloudEnvironment: mockConnectManagedCloudEnvironment, -})); - -vi.mock("../../cloud/managedAuth", () => ({ - readManagedRelayClerkToken: mockReadManagedRelayClerkToken, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: () => ({ - persistence: { - setSavedEnvironmentRegistry: mockSetSavedEnvironmentRegistry, - }, - }), -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, - toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, - useSavedEnvironmentRegistryStore: { - getState: () => ({ - upsert: mockUpsert, - remove: mockRemove, - markConnected: mockMarkConnected, - rename: mockRename, - }), - setState: mockRegistrySetState, - subscribe: vi.fn(() => () => {}), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: mockPatchRuntime, - clear: mockClearRuntime, - }), - }, - waitForSavedEnvironmentRegistryHydration: vi.fn(), - writeSavedEnvironmentBearerToken: mockWriteSavedEnvironmentBearerToken, - writeSavedEnvironmentCredential: mockWriteSavedEnvironmentCredential, -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - managedRelayDpopSigner = actual.ManagedRelayDpopSigner; - return { - ...actual, - bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, - createWsRpcClient: vi.fn(() => ({ - server: { - getConfig: mockClientGetConfig, - }, - terminal: { - onMetadata: vi.fn(() => () => undefined), - }, - orchestration: { - subscribeThread: vi.fn(() => () => {}), - }, - })), - fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, - fetchRemoteSessionState: mockFetchRemoteSessionState, - fetchRemoteDpopSessionState: mockFetchRemoteDpopSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: vi.fn(), -})); - -describe("addSavedEnvironment", () => { - afterEach(() => { - vi.useRealTimers(); - }); - - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - mockSavedRecords = []; - vi.stubGlobal("window", { - desktopBridge: { - ensureSshEnvironment: mockEnsureSshEnvironment, - disconnectSshEnvironment: mockDisconnectSshEnvironment, - fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, - bootstrapSshBearerSession: mockBootstrapSshBearerSession, - fetchSshSessionState: mockFetchSshSessionState, - issueSshWebSocketTicket: vi.fn(), - }, - }); - mockResolveRemotePairingTarget.mockImplementation( - (input: { host?: string; pairingCode?: string }) => ({ - httpBaseUrl: input.host - ? input.host.endsWith("/") - ? input.host - : `${input.host}/` - : "https://remote.example.com/", - wsBaseUrl: input.host - ? input.host.replace(/^http/u, "ws").endsWith("/") - ? input.host.replace(/^http/u, "ws") - : `${input.host.replace(/^http/u, "ws")}/` - : "wss://remote.example.com/", - credential: input.pairingCode ?? "pairing-code", - }), - ); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockFetchRemoteEnvironmentDescriptor.mockReturnValue( - Effect.succeed({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }), - ); - mockBootstrapRemoteBearerSession.mockReturnValue( - Effect.succeed({ - access_token: "bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }), - ); - mockFetchRemoteSessionState.mockReturnValue( - Effect.succeed({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }), - ); - mockFetchRemoteDpopSessionState.mockReturnValue( - Effect.succeed({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }), - ); - mockResolveRemoteWebSocketConnectionUrl.mockReturnValue( - Effect.succeed("wss://remote.example.com/?wsTicket=remote-token"), - ); - mockFetchSshEnvironmentDescriptor.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }); - mockBootstrapSshBearerSession.mockResolvedValue({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); - mockWriteSavedEnvironmentCredential.mockResolvedValue(true); - mockReadManagedRelayClerkToken.mockResolvedValue(null); - mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); - mockFetchSshSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ - kind: "saved", - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }), - ); - mockClientGetConfig.mockResolvedValue({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - }, - }); - mockEnsureSshEnvironment.mockResolvedValue({ - target: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: "ssh-pairing-code", - }); - mockDisconnectSshEnvironment.mockResolvedValue(undefined); - }); - - it("rolls back persisted metadata when bearer token persistence fails", async () => { - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Unable to persist saved environment credentials."); - - expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledTimes(1); - expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "bearer-token", - ); - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([]); - expect(mockUpsert).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("restores unrelated saved environments when credential persistence rollback runs", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-existing"), - label: "Existing environment", - httpBaseUrl: "https://existing.example.com/", - wsBaseUrl: "wss://existing.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Unable to persist saved environment credentials."); - - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([ - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-existing"), - }), - ]); - - await resetEnvironmentServiceForTests(); - }); - - it("persists the server label after saved environment metadata refresh", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockClientGetConfig.mockResolvedValue({ - environment: { - environmentId: EnvironmentId.make("environment-1"), - label: "Julius's Mac mini", - }, - }); - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "100.65.180.100", - host: "remote.example.com", - pairingCode: "123456", - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockRename).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "Julius's Mac mini", - ); - expect(mockSavedRecords).toEqual([ - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-1"), - label: "Julius's Mac mini", - }), - ]); - - await resetEnvironmentServiceForTests(); - }); - - it("installs relay-managed environments with versioned DPoP credentials", async () => { - const { addManagedRelayEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await addManagedRelayEnvironment({ - environmentId: EnvironmentId.make("environment-1"), - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - relayUrl: "https://relay.example.com", - accessToken: "managed-access-token", - relayTraceHeaders: Headers.empty, - }); - - expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - { - version: 1, - method: "dpop", - accessToken: "managed-access-token", - }, - ); - expect(mockFetchRemoteDpopSessionState).toHaveBeenCalledWith({ - httpBaseUrl: "https://managed.example.com/", - accessToken: "managed-access-token", - dpopProof: "dpop-proof", - }); - await resetEnvironmentServiceForTests(); - }); - - it("renews expired managed DPoP credentials through the relay", async () => { - const environmentId = EnvironmentId.make("environment-1"); - mockSavedRecords = [ - { - environmentId, - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - createdAt: "2026-05-25T00:00:00.000Z", - lastConnectedAt: null, - relayManaged: { relayUrl: "https://relay.example.com" }, - }, - ]; - mockReadSavedEnvironmentCredential.mockResolvedValue({ - version: 1, - method: "dpop", - accessToken: "expired-access-token", - }); - mockFetchRemoteDpopSessionState - .mockReturnValueOnce( - Effect.fail( - decodeEnvironmentAuthInvalidError({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-auth-expired", - }), - ), - ) - .mockReturnValue(Effect.succeed({ authenticated: true, scopes: ["orchestration:read"] })); - mockReadManagedRelayClerkToken.mockResolvedValue("clerk-token"); - mockConnectManagedCloudEnvironment.mockReturnValue( - Effect.succeed({ - environmentId, - label: "Managed remote", - httpBaseUrl: "https://managed.example.com/", - wsBaseUrl: "wss://managed.example.com/", - relayUrl: "https://relay.example.com", - accessToken: "renewed-access-token", - relayTraceHeaders: Headers.empty, - }), - ); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - await reconnectSavedEnvironment(environmentId); - - expect(mockConnectManagedCloudEnvironment).toHaveBeenCalledWith({ - clerkToken: "clerk-token", - relayUrl: "https://relay.example.com", - environment: expect.objectContaining({ environmentId }), - }); - expect(mockWriteSavedEnvironmentCredential).toHaveBeenCalledWith(environmentId, { - version: 1, - method: "dpop", - accessToken: "renewed-access-token", - }); - await resetEnvironmentServiceForTests(); - }); - - it("removes an older ssh record when the same target returns a new environment id", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockFetchSshEnvironmentDescriptor.mockResolvedValue({ - environmentId: EnvironmentId.make("environment-2"), - label: "Remote environment", - }); - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Old ssh environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-2"), - }); - - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - environmentId: EnvironmentId.make("environment-2"), - }), - ); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockBootstrapSshBearerSession - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }) - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token-2", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockFetchSshSessionState - .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) - .mockResolvedValueOnce({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - - const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect( - connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockEnsureSshEnvironment).toHaveBeenCalled(); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2); - expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2); - - await resetEnvironmentServiceForTests(); - }); - - it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - const authError = decodeEnvironmentAuthInvalidError({ - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-auth-test", - }); - mockFetchRemoteSessionState.mockReturnValueOnce(Effect.fail(authError)); - - const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await expect( - addSavedEnvironment({ - label: "Remote environment", - host: "remote.example.com", - pairingCode: "123456", - }), - ).rejects.toThrow("Saved environment credential expired. Pair it again."); - - expect(mockEnsureSshEnvironment).not.toHaveBeenCalled(); - expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - mockBootstrapSshBearerSession - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }) - .mockResolvedValueOnce({ - access_token: "ssh-bearer-token-2", - scope: "orchestration:read orchestration:operate terminal:operate review:write relay:read", - }); - mockFetchSshSessionState - .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) - .mockResolvedValueOnce({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - - const createdConnections: Array<{ - readonly environmentId: EnvironmentId; - readonly dispose: ReturnType; - }> = []; - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => { - const connection = { - kind: "saved" as const, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: vi.fn(async () => undefined), - }; - createdConnections.push(connection); - return connection; - }, - ); - - const { - connectDesktopSshEnvironment, - listEnvironmentConnections, - resetEnvironmentServiceForTests, - } = await import("./service"); - - await connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }); - - expect(createdConnections).toHaveLength(2); - expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1); - expect(listEnvironmentConnections()).toHaveLength(1); - expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]); - - await resetEnvironmentServiceForTests(); - }); - - it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const connection = { - kind: "saved" as const, - environmentId: EnvironmentId.make("environment-1"), - knownEnvironment: { - environmentId: EnvironmentId.make("environment-1"), - }, - client: { - terminal: { - onMetadata: vi.fn(() => () => undefined), - }, - }, - ensureBootstrapped: async () => undefined, - reconnect: vi.fn(async () => { - throw new Error("socket closed"); - }), - dispose: async () => undefined, - }; - mockCreateEnvironmentConnection.mockReturnValue(connection); - - const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await addSavedEnvironment({ - label: "Remote environment", - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }); - - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "Unable to persist saved environment credentials.", - ); - - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - lastError: "Unable to persist saved environment credentials.", - }), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("bootstraps a desktop ssh environment through the desktop bridge", async () => { - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect( - connectDesktopSshEnvironment({ - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }), - ).resolves.toMatchObject({ - environmentId: EnvironmentId.make("environment-1"), - }); - - expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox", - hostname: "devbox", - username: null, - port: null, - }, - { issuePairingToken: true }, - ); - expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({ - host: "http://127.0.0.1:3774/", - pairingCode: "ssh-pairing-code", - }); - expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/"); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( - "http://127.0.0.1:3774/", - "ssh-pairing-code", - ); - expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled(); - expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled(); - expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan( - mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - - await resetEnvironmentServiceForTests(); - }); - - it("disconnects the desktop ssh process before removing a saved ssh environment", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { removeSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); - - await removeSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }); - expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - expect(mockDisconnectSshEnvironment.mock.invocationCallOrder[0]).toBeLessThan( - mockRemove.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, - ); - - await resetEnvironmentServiceForTests(); - }); - - it("disconnects a saved ssh environment without removing its saved record", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - - const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).toHaveBeenCalledWith({ - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }); - expect(mockRemove).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("keeps remote environment credentials when disconnecting a non-ssh saved environment", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - - const { disconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockDisconnectSshEnvironment).not.toHaveBeenCalled(); - expect(mockRemove).not.toHaveBeenCalled(); - expect(mockRemoveSavedEnvironmentBearerToken).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("cancels a pending saved environment connection when disconnected", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); - const dispose = vi.fn(async () => undefined); - mockCreateEnvironmentConnection.mockImplementation( - (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ - kind: "saved" as const, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose, - }), - ); - let resolveSessionState!: (value: { - readonly authenticated: true; - readonly scopes: ReadonlyArray<"orchestration:read" | "access:write">; - }) => void; - mockFetchRemoteSessionState.mockReturnValue( - Effect.promise( - () => - new Promise((resolve) => { - resolveSessionState = resolve; - }), - ), - ); - - const { - disconnectSavedEnvironment, - listEnvironmentConnections, - reconnectSavedEnvironment, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const reconnectPromise = reconnectSavedEnvironment(EnvironmentId.make("environment-1")); - await vi.waitFor(() => { - expect(mockFetchRemoteSessionState).toHaveBeenCalledOnce(); - }); - - await disconnectSavedEnvironment(EnvironmentId.make("environment-1")); - resolveSessionState({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - await expect(reconnectPromise).resolves.toBeUndefined(); - - expect(listEnvironmentConnections()).toHaveLength(0); - expect(dispose).toHaveBeenCalledOnce(); - expect(mockPatchRuntime).not.toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - }), - ); - - await resetEnvironmentServiceForTests(); - }); - - it("reissues ssh pairing credentials when connecting after a manual ssh disconnect", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await reconnectSavedEnvironment(EnvironmentId.make("environment-1")); - - expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( - { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - { issuePairingToken: true }, - ); - expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( - "http://127.0.0.1:3774/", - "ssh-pairing-code", - ); - expect(mockWriteSavedEnvironmentBearerToken).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - "ssh-bearer-token", - ); - - await resetEnvironmentServiceForTests(); - }); - - it("rolls back ssh registry metadata when pairing token issuance fails", async () => { - const originalRecord = { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3773/", - wsBaseUrl: "ws://127.0.0.1:3773/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }; - mockSavedRecords = [originalRecord]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockEnsureSshEnvironment.mockResolvedValue({ - target: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - pairingToken: null, - }); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "Desktop SSH launch did not return a pairing token.", - ); - - expect(mockPersistSavedEnvironmentRecord).toHaveBeenCalledWith( - expect.objectContaining({ - httpBaseUrl: "http://127.0.0.1:3774/", - }), - ); - expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([originalRecord]); - expect(mockSavedRecords).toEqual([originalRecord]); - expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); - - await resetEnvironmentServiceForTests(); - }); - - it("surfaces desktop ssh bootstrap failures during saved ssh reconnect", async () => { - mockSavedRecords = [ - { - environmentId: EnvironmentId.make("environment-1"), - label: "Remote environment", - httpBaseUrl: "http://127.0.0.1:3774/", - wsBaseUrl: "ws://127.0.0.1:3774/", - createdAt: "2026-04-14T00:00:00.000Z", - lastConnectedAt: null, - desktopSsh: { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 22, - }, - }, - ]; - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockEnsureSshEnvironment.mockRejectedValue(new Error("SSH command timed out after 60000ms.")); - - const { reconnectSavedEnvironment, resetEnvironmentServiceForTests } = - await import("./service"); - - await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( - "SSH command timed out after 60000ms.", - ); - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "connecting", - }), - ); - expect(mockPatchRuntime).toHaveBeenCalledWith( - EnvironmentId.make("environment-1"), - expect.objectContaining({ - connectionState: "error", - lastError: "SSH command timed out after 60000ms.", - }), - ); - - await resetEnvironmentServiceForTests(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts deleted file mode 100644 index e7c15ec6b32..00000000000 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mockCreateEnvironmentConnection = vi.fn(); -const mockCreateWsRpcClient = vi.fn(); -const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(() => "ws://remote.example.test"); -const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); -const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); -const mockSavedEnvironmentRegistrySubscribe = vi.fn(); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn(); - -function MockWsTransport() { - return undefined; -} - -vi.mock("../primary", () => ({ - getPrimaryKnownEnvironment: vi.fn(() => ({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - environmentId: EnvironmentId.make("env-1"), - })), -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: vi.fn(), - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: vi.fn(), - useSavedEnvironmentRegistryStore: { - subscribe: mockSavedEnvironmentRegistrySubscribe, - getState: () => ({ - upsert: vi.fn(), - remove: vi.fn(), - markConnected: vi.fn(), - rename: vi.fn(), - }), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), - }), - }, - waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken: vi.fn(), - writeSavedEnvironmentCredential: vi.fn(), -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createWsRpcClient: mockCreateWsRpcClient, - fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: MockWsTransport, -})); - -vi.mock("~/composerDraftStore", () => ({ - markPromotedDraftThreadByRef: vi.fn(), - markPromotedDraftThreadsByRef: vi.fn(), - useComposerDraftStore: { - getState: () => ({ - getDraftThreadByRef: vi.fn(() => null), - clearDraftThread: vi.fn(), - }), - }, -})); - -vi.mock("~/localApi", () => ({ - ensureLocalApi: vi.fn(() => ({ - persistence: { - setSavedEnvironmentRegistry: vi.fn(async () => undefined), - }, - })), -})); - -vi.mock("~/lib/terminalStateCleanup", () => ({ - collectActiveTerminalThreadIds: vi.fn(() => []), -})); - -vi.mock("~/orchestrationEventEffects", () => ({ - deriveOrchestrationBatchEffects: vi.fn(() => ({ - promotedThreadRefs: [], - invalidatedProviderState: false, - })), -})); - -vi.mock("~/store", () => ({ - useStore: { - getState: () => ({ - syncServerShellSnapshot: vi.fn(), - syncServerThreadDetail: vi.fn(), - removeServerThreadDetail: vi.fn(), - applyServerShellEvent: vi.fn(), - }), - }, - selectProjectsAcrossEnvironments: vi.fn(() => []), - selectSidebarThreadSummaryByRef: vi.fn(() => null), - selectThreadByRef: vi.fn(() => null), - selectThreadsAcrossEnvironments: vi.fn(() => []), -})); - -vi.mock("~/terminalStateStore", () => ({ - useTerminalStateStore: { - getState: () => ({ - applyTerminalEvent: vi.fn(), - removeTerminalState: vi.fn(), - clearTerminalSelection: vi.fn(), - }), - }, -})); - -vi.mock("~/uiStateStore", () => ({ - useUiStateStore: { - getState: () => ({ - clearThreadUi: vi.fn(), - syncPromotedDraftThreadRefs: vi.fn(), - }), - }, -})); - -const savedRecord = { - environmentId: EnvironmentId.make("env-saved"), - label: "Remote environment", - httpBaseUrl: "https://remote.example.test/", - wsBaseUrl: "wss://remote.example.test/", -}; - -const configSnapshot = { - environment: { - environmentId: savedRecord.environmentId, - label: "Remote environment", - }, -}; - -function createClient() { - return { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - server: { - getConfig: vi.fn(async () => configSnapshot), - subscribeConfig: vi.fn(() => () => undefined), - subscribeLifecycle: vi.fn(() => () => undefined), - subscribeAuthAccess: vi.fn(() => () => undefined), - refreshProviders: vi.fn(async () => undefined), - upsertKeybinding: vi.fn(async () => undefined), - getSettings: vi.fn(async () => undefined), - updateSettings: vi.fn(async () => undefined), - }, - orchestration: { - subscribeShell: vi.fn(() => () => undefined), - subscribeThread: vi.fn(() => () => undefined), - dispatchCommand: vi.fn(async () => undefined), - getTurnDiff: vi.fn(async () => undefined), - getFullThreadDiff: vi.fn(async () => undefined), - }, - terminal: { - open: vi.fn(async () => undefined), - write: vi.fn(async () => undefined), - resize: vi.fn(async () => undefined), - clear: vi.fn(async () => undefined), - restart: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - projects: { - searchEntries: vi.fn(async () => []), - writeFile: vi.fn(async () => undefined), - }, - shell: { - openInEditor: vi.fn(async () => undefined), - }, - git: { - pull: vi.fn(async () => undefined), - refreshStatus: vi.fn(async () => undefined), - onStatus: vi.fn(() => () => undefined), - runStackedAction: vi.fn(async () => ({})), - listBranches: vi.fn(async () => []), - createWorktree: vi.fn(async () => undefined), - removeWorktree: vi.fn(async () => undefined), - createBranch: vi.fn(async () => undefined), - checkout: vi.fn(async () => undefined), - init: vi.fn(async () => undefined), - resolvePullRequest: vi.fn(async () => undefined), - preparePullRequestThread: vi.fn(async () => undefined), - }, - }; -} - -describe("saved environment startup", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.resetModules(); - vi.clearAllMocks(); - - mockFetchRemoteSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read", "access:write"], - }); - mockGetSavedEnvironmentRecord.mockImplementation((environmentId: EnvironmentId) => - environmentId === savedRecord.environmentId ? savedRecord : null, - ); - mockListSavedEnvironmentRecords.mockReturnValue([savedRecord]); - mockSavedEnvironmentRegistrySubscribe.mockReturnValue(() => undefined); - mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); - mockReadSavedEnvironmentBearerToken.mockResolvedValue("saved-bearer-token"); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockCreateWsRpcClient.mockImplementation(() => createClient()); - mockCreateEnvironmentConnection.mockImplementation((input) => { - if (input.kind === "saved") { - queueMicrotask(() => { - input.onConfigSnapshot?.(configSnapshot); - }); - } - - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - dispose: vi.fn(async () => undefined), - }; - }); - }); - - afterEach(async () => { - const { resetEnvironmentServiceForTests } = await import("./service"); - await resetEnvironmentServiceForTests(); - vi.useRealTimers(); - }); - - it("uses the initial config snapshot instead of issuing an extra getConfig call", async () => { - const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - await vi.runAllTimersAsync(); - - const savedConnectionCall = mockCreateEnvironmentConnection.mock.calls.find( - ([input]) => input.kind === "saved", - ); - expect(savedConnectionCall).toBeDefined(); - - const savedClient = savedConnectionCall?.[0]?.client; - expect(savedClient.server.getConfig).not.toHaveBeenCalled(); - expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("coalesces hydration and registry sync so the initial saved connection only starts once", async () => { - let finishHydration!: () => void; - let finishTokenRead!: (token: string) => void; - - mockWaitForSavedEnvironmentRegistryHydration.mockImplementation( - () => - new Promise((resolve) => { - finishHydration = () => resolve(); - }), - ); - mockReadSavedEnvironmentBearerToken.mockImplementation( - () => - new Promise((resolve) => { - finishTokenRead = resolve; - }), - ); - - const { startEnvironmentConnectionService, resetEnvironmentServiceForTests } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const registryListener = mockSavedEnvironmentRegistrySubscribe.mock.calls[0]?.[0]; - expect(registryListener).toBeTypeOf("function"); - - registryListener?.(); - finishHydration(); - await vi.waitFor(() => { - expect(mockReadSavedEnvironmentBearerToken).toHaveBeenCalledTimes(1); - }); - - finishTokenRead("saved-bearer-token"); - await vi.runAllTimersAsync(); - - const savedConnectionCalls = mockCreateEnvironmentConnection.mock.calls.filter( - ([input]) => input.kind === "saved", - ); - expect(savedConnectionCalls).toHaveLength(1); - expect(mockFetchRemoteSessionState).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts deleted file mode 100644 index 675a4868032..00000000000 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ /dev/null @@ -1,648 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationShellSnapshot, -} from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const mockSubscribeThread = vi.fn(); -const mockThreadUnsubscribe = vi.fn(); -const mockCreateEnvironmentConnection = vi.fn(); -const mockCreateWsRpcClient = vi.fn(); -const mockWaitForSavedEnvironmentRegistryHydration = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); -const mockGetSavedEnvironmentRecord = vi.fn(); -const mockReadSavedEnvironmentBearerToken = vi.fn(); -const mockReadSavedEnvironmentCredential = vi.fn(); -const mockSavedEnvironmentRegistrySubscribe = vi.fn(); -const mockGetPrimaryKnownEnvironment = vi.hoisted(() => vi.fn()); -const mockFetchRemoteSessionState = vi.fn(); -const mockResolveRemoteWebSocketConnectionUrl = vi.fn(async () => "ws://remote.example.test/ws"); -const mockRemoteHttpRunPromise = vi.fn((effect: Promise) => effect); -const mockConnectionReconnects: Array> = []; -let savedEnvironmentRegistryListener: (() => void) | null = null; - -function MockWsTransport() { - return undefined; -} - -vi.mock("../primary", () => ({ - getPrimaryKnownEnvironment: mockGetPrimaryKnownEnvironment, -})); - -vi.mock("../../lib/runtime", () => ({ - webRuntime: { - runPromise: mockRemoteHttpRunPromise, - }, -})); - -vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated: vi.fn(() => true), - listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, - persistSavedEnvironmentRecord: vi.fn(), - readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, - readSavedEnvironmentCredential: mockReadSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken: vi.fn(), - useSavedEnvironmentRegistryStore: { - subscribe: mockSavedEnvironmentRegistrySubscribe, - getState: () => ({ - upsert: vi.fn(), - remove: vi.fn(), - markConnected: vi.fn(), - rename: vi.fn(), - }), - }, - useSavedEnvironmentRuntimeStore: { - getState: () => ({ - ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), - }), - }, - waitForSavedEnvironmentRegistryHydration: mockWaitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken: vi.fn(), - writeSavedEnvironmentCredential: vi.fn(), -})); - -vi.mock("./connection", async (importOriginal) => ({ - ...(await importOriginal()), - createEnvironmentConnection: mockCreateEnvironmentConnection, -})); - -vi.mock("@t3tools/client-runtime", async (importOriginal) => { - const actual = await importOriginal(); - const stubWsClient: WsRpcClient = { - dispose: async () => undefined, - reconnect: async () => undefined, - isHeartbeatFresh: () => false, - cloud: { - getRelayClientStatus: vi.fn(), - installRelayClient: vi.fn(), - }, - orchestration: { - dispatchCommand: vi.fn(), - getTurnDiff: vi.fn(), - getFullThreadDiff: vi.fn(), - getArchivedShellSnapshot: vi.fn(), - subscribeShell: vi.fn(() => () => undefined), - subscribeThread: mockSubscribeThread, - }, - terminal: { - open: vi.fn(), - attach: vi.fn(() => () => undefined), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - restart: vi.fn(), - close: vi.fn(), - onEvent: vi.fn(() => () => undefined), - onMetadata: vi.fn(() => () => undefined), - }, - projects: { - searchEntries: vi.fn(), - writeFile: vi.fn(), - }, - filesystem: { - browse: vi.fn(), - }, - sourceControl: { - lookupRepository: vi.fn(), - cloneRepository: vi.fn(), - publishRepository: vi.fn(), - }, - shell: { - openInEditor: vi.fn(), - }, - vcs: { - pull: vi.fn(), - refreshStatus: vi.fn(), - onStatus: vi.fn(() => () => undefined), - listRefs: vi.fn(), - createWorktree: vi.fn(), - removeWorktree: vi.fn(), - createRef: vi.fn(), - switchRef: vi.fn(), - init: vi.fn(), - }, - git: { - runStackedAction: vi.fn(), - resolvePullRequest: vi.fn(), - preparePullRequestThread: vi.fn(), - }, - review: { - getDiffPreview: vi.fn(), - }, - server: { - getConfig: vi.fn(), - refreshProviders: vi.fn(), - discoverSourceControl: vi.fn(), - updateProvider: vi.fn(), - upsertKeybinding: vi.fn(), - removeKeybinding: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), - subscribeConfig: vi.fn(() => () => undefined), - subscribeLifecycle: vi.fn(() => () => undefined), - subscribeAuthAccess: vi.fn(() => () => undefined), - getTraceDiagnostics: vi.fn(), - getProcessDiagnostics: vi.fn(), - getProcessResourceHistory: vi.fn(), - signalProcess: vi.fn(), - }, - }; - return { - ...actual, - createWsRpcClient: vi.fn(() => stubWsClient), - fetchRemoteSessionState: mockFetchRemoteSessionState, - resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, - }; -}); - -vi.mock("../../rpc/wsTransport", () => ({ - WsTransport: MockWsTransport, -})); - -function makeThreadShellSnapshot(params: { - readonly threadId: ThreadId; - readonly sessionStatus?: - | "idle" - | "starting" - | "running" - | "ready" - | "interrupted" - | "stopped" - | "error"; - readonly hasPendingApprovals?: boolean; - readonly hasPendingUserInput?: boolean; - readonly hasActionableProposedPlan?: boolean; -}): OrchestrationShellSnapshot { - const projectId = ProjectId.make("project-1"); - const turnId = TurnId.make("turn-1"); - - return { - snapshotSequence: 1, - projects: [], - updatedAt: "2026-04-13T00:00:00.000Z", - threads: [ - { - id: params.threadId, - projectId, - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: - params.sessionStatus === "running" - ? { - turnId, - state: "running", - requestedAt: "2026-04-13T00:00:00.000Z", - startedAt: "2026-04-13T00:00:01.000Z", - completedAt: null, - assistantMessageId: null, - } - : null, - createdAt: "2026-04-13T00:00:00.000Z", - updatedAt: "2026-04-13T00:00:00.000Z", - archivedAt: null, - session: params.sessionStatus - ? { - threadId: params.threadId, - status: params.sessionStatus, - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: params.sessionStatus === "running" ? turnId : null, - lastError: null, - updatedAt: "2026-04-13T00:00:00.000Z", - } - : null, - latestUserMessageAt: null, - hasPendingApprovals: params.hasPendingApprovals ?? false, - hasPendingUserInput: params.hasPendingUserInput ?? false, - hasActionableProposedPlan: params.hasActionableProposedPlan ?? false, - }, - ], - }; -} - -describe("retainThreadDetailSubscription", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.resetModules(); - vi.clearAllMocks(); - mockGetPrimaryKnownEnvironment.mockReturnValue({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - environmentId: EnvironmentId.make("env-1"), - }); - - mockThreadUnsubscribe.mockImplementation(() => undefined); - mockSubscribeThread.mockImplementation(() => mockThreadUnsubscribe); - mockCreateWsRpcClient.mockReturnValue({ - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-remote"), - label: "Remote env", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - })), - }, - isHeartbeatFresh: vi.fn(() => true), - orchestration: { - subscribeThread: mockSubscribeThread, - }, - }); - mockCreateEnvironmentConnection.mockImplementation((input) => { - const reconnect = vi.fn(async () => undefined); - mockConnectionReconnects.push(reconnect); - queueMicrotask(() => { - input.onConfigSnapshot?.({ - environment: { - environmentId: input.knownEnvironment.environmentId, - label: input.knownEnvironment.label, - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }); - }); - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect, - dispose: vi.fn(async () => undefined), - }; - }); - savedEnvironmentRegistryListener = null; - mockSavedEnvironmentRegistrySubscribe.mockImplementation((listener: () => void) => { - savedEnvironmentRegistryListener = listener; - return () => { - if (savedEnvironmentRegistryListener === listener) { - savedEnvironmentRegistryListener = null; - } - }; - }); - mockWaitForSavedEnvironmentRegistryHydration.mockResolvedValue(undefined); - mockListSavedEnvironmentRecords.mockReturnValue([]); - mockGetSavedEnvironmentRecord.mockReturnValue(null); - mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); - mockReadSavedEnvironmentCredential.mockImplementation(async () => { - const token = await mockReadSavedEnvironmentBearerToken(); - return token ? { version: 1, method: "bearer", token } : null; - }); - mockFetchRemoteSessionState.mockResolvedValue({ - authenticated: true, - scopes: ["orchestration:read"], - }); - mockConnectionReconnects.length = 0; - }); - - afterEach(async () => { - const { resetEnvironmentServiceForTests } = await import("./service"); - await resetEnvironmentServiceForTests(); - vi.unstubAllGlobals(); - vi.useRealTimers(); - }); - - it("keeps thread detail subscriptions warm across releases until idle eviction", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-1"); - - const releaseFirst = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - releaseFirst(); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - const releaseSecond = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - releaseSecond(); - await vi.advanceTimersByTimeAsync(2 * 60 * 1000); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - await vi.advanceTimersByTimeAsync(28 * 60 * 1000); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("does not start the primary connection until the known environment has an id", async () => { - mockGetPrimaryKnownEnvironment.mockReturnValue({ - id: "env-1", - label: "Primary environment", - source: "window-origin", - target: { - httpBaseUrl: "http://127.0.0.1:3000/", - wsBaseUrl: "ws://127.0.0.1:3000/", - }, - }); - const { - listEnvironmentConnections, - resetEnvironmentServiceForTests, - startEnvironmentConnectionService, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - - expect(mockCreateEnvironmentConnection).not.toHaveBeenCalled(); - expect(listEnvironmentConnections()).toEqual([]); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-active"); - - const connectionInput = mockCreateEnvironmentConnection.mock.calls[0]?.[0]; - expect(connectionInput).toBeDefined(); - - connectionInput.syncShellSnapshot( - makeThreadShellSnapshot({ - threadId, - sessionStatus: "ready", - hasPendingApprovals: true, - }), - environmentId, - ); - - const release = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - release(); - await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - connectionInput.applyShellEvent( - { - kind: "thread-upserted", - sequence: 2, - thread: makeThreadShellSnapshot({ - threadId, - sessionStatus: "idle", - }).threads[0]!, - }, - environmentId, - ); - - await vi.advanceTimersByTimeAsync(30 * 60 * 1000); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("reattaches retained thread detail subscriptions after a saved environment reconnect replaces the client", async () => { - const environmentId = EnvironmentId.make("env-remote"); - const threadId = ThreadId.make("thread-reconnect"); - const record = { - environmentId, - label: "Remote env", - httpBaseUrl: "http://remote.example.test", - wsBaseUrl: "ws://remote.example.test", - createdAt: "2026-05-01T00:00:00.000Z", - lastConnectedAt: "2026-05-01T00:00:00.000Z", - }; - mockListSavedEnvironmentRecords.mockReturnValue([record]); - mockGetSavedEnvironmentRecord.mockReturnValue(record); - mockReadSavedEnvironmentBearerToken.mockResolvedValue("bearer-token"); - - const { - disconnectSavedEnvironment, - listEnvironmentConnections, - reconnectSavedEnvironment, - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - savedEnvironmentRegistryListener?.(); - await vi.waitFor(() => { - expect( - listEnvironmentConnections().some( - (connection) => connection.environmentId === environmentId, - ), - ).toBe(true); - }); - const createConnectionCallsBeforeReconnect = mockCreateEnvironmentConnection.mock.calls.length; - - const release = retainThreadDetailSubscription(environmentId, threadId); - expect(mockSubscribeThread).toHaveBeenCalledTimes(1); - - await disconnectSavedEnvironment(environmentId); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - expect( - listEnvironmentConnections().some((connection) => connection.environmentId === environmentId), - ).toBe(false); - - const reconnectPromise = reconnectSavedEnvironment(environmentId); - await vi.advanceTimersByTimeAsync(200); - await reconnectPromise; - await vi.waitFor(() => { - expect(mockCreateEnvironmentConnection).toHaveBeenCalledTimes( - createConnectionCallsBeforeReconnect + 1, - ); - expect(mockSubscribeThread).toHaveBeenCalledTimes(2); - }); - - release(); - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("keeps healthy environment streams connected when the browser resumes from the background", async () => { - let visibilityState: DocumentVisibilityState = "visible"; - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - vi.stubGlobal("document", { - addEventListener: documentTarget.addEventListener.bind(documentTarget), - removeEventListener: documentTarget.removeEventListener.bind(documentTarget), - get visibilityState() { - return visibilityState; - }, - }); - vi.stubGlobal("window", { - addEventListener: windowTarget.addEventListener.bind(windowTarget), - removeEventListener: windowTarget.removeEventListener.bind(windowTarget), - }); - - const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = - await import("./service"); - mockCreateEnvironmentConnection.mockImplementation((input) => { - const reconnect = vi.fn(async () => undefined); - mockConnectionReconnects.push(reconnect); - queueMicrotask(() => { - input.onConfigSnapshot?.({ - environment: { - environmentId: input.knownEnvironment.environmentId, - label: input.knownEnvironment.label, - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }); - }); - return { - kind: input.kind, - environmentId: input.knownEnvironment.environmentId, - knownEnvironment: input.knownEnvironment, - client: { - ...input.client, - isHeartbeatFresh: vi.fn(() => true), - }, - ensureBootstrapped: vi.fn(async () => undefined), - reconnect, - dispose: vi.fn(async () => undefined), - }; - }); - - const stop = startEnvironmentConnectionService(new QueryClient()); - expect(mockConnectionReconnects).toHaveLength(1); - - visibilityState = "hidden"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - visibilityState = "visible"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("reconnects stale environment streams when the browser resumes from the background", async () => { - let visibilityState: DocumentVisibilityState = "visible"; - const documentTarget = new EventTarget(); - const windowTarget = new EventTarget(); - vi.stubGlobal("document", { - addEventListener: documentTarget.addEventListener.bind(documentTarget), - removeEventListener: documentTarget.removeEventListener.bind(documentTarget), - get visibilityState() { - return visibilityState; - }, - }); - vi.stubGlobal("window", { - addEventListener: windowTarget.addEventListener.bind(windowTarget), - removeEventListener: windowTarget.removeEventListener.bind(windowTarget), - }); - mockCreateWsRpcClient.mockReturnValue({ - server: { - getConfig: vi.fn(async () => ({ - environment: { - environmentId: EnvironmentId.make("env-remote"), - label: "Remote env", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - })), - }, - isHeartbeatFresh: vi.fn(() => false), - orchestration: { - subscribeThread: mockSubscribeThread, - }, - }); - - const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = - await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - expect(mockConnectionReconnects).toHaveLength(1); - - visibilityState = "hidden"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); - - visibilityState = "visible"; - documentTarget.dispatchEvent(new Event("visibilitychange")); - expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("allows a larger idle cache before capacity eviction starts", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - - for (let index = 0; index < 12; index += 1) { - const release = retainThreadDetailSubscription( - environmentId, - ThreadId.make(`thread-${index + 1}`), - ); - release(); - } - - expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); - - stop(); - await resetEnvironmentServiceForTests(); - }); - - it("disposes cached thread detail subscriptions when the environment service resets", async () => { - const { - retainThreadDetailSubscription, - startEnvironmentConnectionService, - resetEnvironmentServiceForTests, - } = await import("./service"); - - const stop = startEnvironmentConnectionService(new QueryClient()); - const environmentId = EnvironmentId.make("env-1"); - const threadId = ThreadId.make("thread-2"); - - const release = retainThreadDetailSubscription(environmentId, threadId); - release(); - - await resetEnvironmentServiceForTests(); - expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); - - stop(); - }); -}); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts deleted file mode 100644 index 35b2a6f7a85..00000000000 --- a/apps/web/src/environments/runtime/service.ts +++ /dev/null @@ -1,2084 +0,0 @@ -import { - AuthEnvironmentScope, - type DesktopSshEnvironmentBootstrap, - type DesktopSshEnvironmentTarget, - type EnvironmentId, - type OrchestrationEvent, - type OrchestrationShellSnapshot, - type OrchestrationShellStreamEvent, - type ServerConfig, - EnvironmentAuthInvalidError, - ThreadId, -} from "@t3tools/contracts"; -import { - createWsRpcClient as createBaseWsRpcClient, - type WsRpcClient, - bootstrapRemoteBearerSession, - fetchRemoteEnvironmentDescriptor, - fetchRemoteDpopSessionState, - fetchRemoteSessionState, - type ManagedRelayDpopProofInput, - ManagedRelayDpopSigner, - resolveRemoteDpopWebSocketConnectionUrl, - resolveRemoteWebSocketConnectionUrl, -} from "@t3tools/client-runtime"; - -import { type QueryClient } from "@tanstack/react-query"; -import { Throttler } from "@tanstack/react-pacer"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { Headers, HttpTraceContext } from "effect/unstable/http"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -import { - createKnownEnvironment, - getKnownEnvironmentWsBaseUrl, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; - -import { - markPromotedDraftThreadByRef, - markPromotedDraftThreadsByRef, - useComposerDraftStore, -} from "~/composerDraftStore"; -import { ensureLocalApi } from "~/localApi"; -import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup"; -import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; -import { getPrimaryKnownEnvironment } from "../primary"; -import { webRuntime } from "../../lib/runtime"; -import { connectManagedCloudEnvironment } from "../../cloud/linkEnvironment"; -import { readManagedRelayClerkToken } from "../../cloud/managedAuth"; - -import { - getSavedEnvironmentRecord, - hasSavedEnvironmentRegistryHydrated, - listSavedEnvironmentRecords, - persistSavedEnvironmentRecord, - readSavedEnvironmentCredential, - removeSavedEnvironmentBearerToken, - type SavedEnvironmentRecord, - type SavedEnvironmentCredential, - toPersistedSavedEnvironmentRecord, - useSavedEnvironmentRegistryStore, - useSavedEnvironmentRuntimeStore, - waitForSavedEnvironmentRegistryHydration, - writeSavedEnvironmentBearerToken, - writeSavedEnvironmentCredential, -} from "./catalog"; -import { - createEnvironmentConnection, - createEnvironmentConnectionAttemptRegistry, - EnvironmentConnectionAttemptCancelledError, - type EnvironmentConnection, -} from "./connection"; -import { - useStore, - selectProjectsAcrossEnvironments, - selectSidebarThreadSummaryByRef, - selectThreadByRef, - selectThreadsAcrossEnvironments, -} from "~/store"; -import { useTerminalUiStateStore } from "~/terminalUiStateStore"; -import { useUiStateStore } from "~/uiStateStore"; -import { getServerConfig } from "../../rpc/serverState"; -import { WsTransport } from "~/rpc/wsTransport"; -import { appendVersionMismatchHint, resolveServerConfigVersionMismatch } from "../../versionSkew"; -import { - deriveLogicalProjectKeyFromSettings, - derivePhysicalProjectKey, -} from "../../logicalProject"; - -const decodeIssuedBearerScopes = Schema.decodeUnknownSync(Schema.Array(AuthEnvironmentScope)); -import { getClientSettings } from "~/hooks/useSettings"; -import { subscribeTerminalMetadata, terminalSessionManager } from "../../terminalSessionState"; -import { resetWsReconnectBackoff } from "~/rpc/wsConnectionState"; -import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; - -type EnvironmentServiceState = { - readonly queryClient: QueryClient; - readonly queryInvalidationThrottler: Throttler<() => void>; - refCount: number; - stop: () => void; -}; - -type ThreadDetailSubscriptionEntry = { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - unsubscribe: () => void; - unsubscribeConnectionListener: (() => void) | null; - refCount: number; - lastAccessedAt: number; - evictionTimeoutId: ReturnType | null; -}; - -const environmentConnections = new Map(); -const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); - -function isSavedEnvironmentConnectionCancelledError( - error: unknown, -): error is EnvironmentConnectionAttemptCancelledError { - return error instanceof EnvironmentConnectionAttemptCancelledError; -} - -interface PendingSavedEnvironmentConnection { - readonly isCurrent: () => boolean; - readonly promise: Promise; -} - -const savedEnvironmentConnectionAttempts = createEnvironmentConnectionAttemptRegistry(); -const pendingSavedEnvironmentConnections = new Map< - EnvironmentId, - PendingSavedEnvironmentConnection ->(); -const environmentConnectionListeners = new Set<() => void>(); -const providerInvalidationListeners = new Set<() => void>(); -const threadDetailSubscriptions = new Map(); -const lastAppliedProjectionVersionByEnvironment = new Map< - EnvironmentId, - { - readonly sequence: number; - readonly updatedAt: string | null; - } ->(); -const terminalMetadataSubscriptions = new Map void>(); - -let activeService: EnvironmentServiceState | null = null; -let needsProviderInvalidation = false; -let lastBrowserHiddenAt: number | null = null; -let lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; - -// TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): -// This file still owns web's legacy thread-detail subscription cache. Mobile -// uses createThreadDetailManager from @t3tools/client-runtime for the same -// retain/reconnect/evict lifecycle. When touching this logic, prefer migrating -// web to the shared manager or extracting the missing adapter layer instead of -// adding more behavior here. -// -// Thread detail subscription cache policy: -// - Active consumers keep a subscription retained via refCount. -// - Released subscriptions stay warm for a longer idle TTL to avoid churn -// while moving around the UI. -// - Threads with active work or pending user action are sticky and are never -// evicted while they remain non-idle. -// - Capacity eviction only targets idle cached subscriptions. -const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; -const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; -const BROWSER_RESUME_RECONNECT_COOLDOWN_MS = 2_000; -const INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS = 150; -const NOOP = () => undefined; -const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; - -const createManagedRelayDpopProof = (input: ManagedRelayDpopProofInput) => - Effect.gen(function* () { - const signer = yield* ManagedRelayDpopSigner; - return yield* signer.createProof(input); - }); - -function createDeferredPromise() { - let resolve: ((value: T) => void) | null = null; - const promise = new Promise((nextResolve) => { - resolve = nextResolve; - }); - - return { - promise, - resolve: (value: T) => { - resolve?.(value); - resolve = null; - }, - }; -} - -async function waitForConfigSnapshot( - promise: Promise, - timeoutMs: number, -): Promise { - return await new Promise((resolve) => { - const timeoutId = globalThis.setTimeout(() => resolve(null), timeoutMs); - promise.then( - (config) => { - clearTimeout(timeoutId); - resolve(config); - }, - () => { - clearTimeout(timeoutId); - resolve(null); - }, - ); - }); -} - -function createSavedEnvironmentSyncScheduler() { - let activeSync: Promise | null = null; - let queued = false; - - const run = async (): Promise => { - do { - queued = false; - await syncSavedEnvironmentConnections(listSavedEnvironmentRecords()); - } while (queued); - }; - - return () => { - if (activeSync) { - queued = true; - return activeSync; - } - - activeSync = run() - .catch(() => undefined) - .finally(() => { - activeSync = null; - }); - - return activeSync; - }; -} -function compareAppliedProjectionVersion( - left: { readonly sequence: number; readonly updatedAt: string | null }, - right: { readonly sequence: number; readonly updatedAt: string | null }, -): number { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - - const leftUpdatedAt = left.updatedAt ?? ""; - const rightUpdatedAt = right.updatedAt ?? ""; - if (leftUpdatedAt === rightUpdatedAt) { - return 0; - } - - return leftUpdatedAt < rightUpdatedAt ? -1 : 1; -} - -function toAppliedProjectionVersion( - snapshot: Pick, -): { - readonly sequence: number; - readonly updatedAt: string; -} { - return { - sequence: snapshot.snapshotSequence, - updatedAt: snapshot.updatedAt, - }; -} - -export function shouldApplyProjectionSnapshot(input: { - readonly current: { - readonly sequence: number; - readonly updatedAt: string | null; - } | null; - readonly next: Pick; -}): boolean { - if (input.current === null) { - return true; - } - - return compareAppliedProjectionVersion(input.current, toAppliedProjectionVersion(input.next)) < 0; -} - -export function shouldApplyProjectionEvent(input: { - readonly current: { - readonly sequence: number; - readonly updatedAt: string | null; - } | null; - readonly sequence: number; -}): boolean { - if (input.current === null) { - return true; - } - - return input.sequence > input.current.sequence; -} - -function readLastAppliedProjectionVersion(environmentId: EnvironmentId): { - readonly sequence: number; - readonly updatedAt: string | null; -} | null { - return lastAppliedProjectionVersionByEnvironment.get(environmentId) ?? null; -} - -function markAppliedProjectionSnapshot( - environmentId: EnvironmentId, - snapshot: Pick, -): void { - const nextVersion = toAppliedProjectionVersion(snapshot); - const currentVersion = readLastAppliedProjectionVersion(environmentId); - if ( - currentVersion !== null && - compareAppliedProjectionVersion(currentVersion, nextVersion) >= 0 - ) { - return; - } - - lastAppliedProjectionVersionByEnvironment.set(environmentId, nextVersion); -} - -function markAppliedProjectionEvent(environmentId: EnvironmentId, sequence: number): void { - const currentVersion = readLastAppliedProjectionVersion(environmentId); - if (currentVersion !== null && sequence <= currentVersion.sequence) { - return; - } - - lastAppliedProjectionVersionByEnvironment.set(environmentId, { - sequence, - updatedAt: currentVersion?.updatedAt ?? null, - }); -} -function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { - return scopedThreadKey(scopeThreadRef(environmentId, threadId)); -} - -function clearThreadDetailSubscriptionEviction( - entry: ThreadDetailSubscriptionEntry, -): ThreadDetailSubscriptionEntry { - if (entry.evictionTimeoutId !== null) { - clearTimeout(entry.evictionTimeoutId); - entry.evictionTimeoutId = null; - } - return entry; -} - -function isNonIdleThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - const threadRef = scopeThreadRef(entry.environmentId, entry.threadId); - const state = useStore.getState(); - const sidebarThread = selectSidebarThreadSummaryByRef(state, threadRef); - - // Prefer shell/sidebar state first because it carries the coarse thread - // readiness flags used throughout the UI (pending approvals/input/plan). - if (sidebarThread) { - if ( - sidebarThread.hasPendingApprovals || - sidebarThread.hasPendingUserInput || - sidebarThread.hasActionableProposedPlan - ) { - return true; - } - - const orchestrationStatus = sidebarThread.session?.orchestrationStatus; - if ( - orchestrationStatus && - orchestrationStatus !== "idle" && - orchestrationStatus !== "stopped" - ) { - return true; - } - - if (sidebarThread.latestTurn?.state === "running") { - return true; - } - } - - const thread = selectThreadByRef(state, threadRef); - if (!thread) { - return false; - } - - const orchestrationStatus = thread.session?.orchestrationStatus; - return ( - Boolean( - orchestrationStatus && orchestrationStatus !== "idle" && orchestrationStatus !== "stopped", - ) || - thread.latestTurn?.state === "running" || - thread.pendingSourceProposedPlan !== undefined - ); -} - -function shouldEvictThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - return entry.refCount === 0 && !isNonIdleThreadDetailSubscription(entry); -} - -function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { - if (entry.unsubscribeConnectionListener !== null) { - entry.unsubscribeConnectionListener(); - entry.unsubscribeConnectionListener = null; - } - if (entry.unsubscribe !== NOOP) { - return true; - } - - const connection = readEnvironmentConnection(entry.environmentId); - if (!connection) { - return false; - } - - entry.unsubscribe = connection.client.orchestration.subscribeThread( - { threadId: entry.threadId }, - (item) => { - if (item.kind === "snapshot") { - useStore.getState().syncServerThreadDetail(item.snapshot.thread, entry.environmentId); - return; - } - applyEnvironmentThreadDetailEvent(item.event, entry.environmentId); - }, - ); - return true; -} - -function watchThreadDetailSubscriptionConnection(entry: ThreadDetailSubscriptionEntry): void { - if (entry.unsubscribeConnectionListener !== null) { - return; - } - - entry.unsubscribeConnectionListener = subscribeEnvironmentConnections(() => { - if (attachThreadDetailSubscription(entry)) { - entry.lastAccessedAt = Date.now(); - } - }); - attachThreadDetailSubscription(entry); -} - -function disposeThreadDetailSubscriptionByKey(key: string): boolean { - const entry = threadDetailSubscriptions.get(key); - if (!entry) { - return false; - } - - clearThreadDetailSubscriptionEviction(entry); - entry.unsubscribeConnectionListener?.(); - entry.unsubscribeConnectionListener = null; - threadDetailSubscriptions.delete(key); - entry.unsubscribe(); - entry.unsubscribe = NOOP; - return true; -} - -function disposeThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const [key, entry] of threadDetailSubscriptions) { - if (entry.environmentId === environmentId) { - disposeThreadDetailSubscriptionByKey(key); - } - } -} - -function detachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId !== environmentId) { - continue; - } - entry.unsubscribe(); - entry.unsubscribe = NOOP; - watchThreadDetailSubscriptionConnection(entry); - } -} - -function attachThreadDetailSubscriptionsForEnvironment(environmentId: EnvironmentId): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId === environmentId) { - attachThreadDetailSubscription(entry); - } - } -} - -function reconcileThreadDetailSubscriptionsForEnvironment( - environmentId: EnvironmentId, - threadIds: ReadonlyArray, -): void { - const activeThreadIds = new Set(threadIds); - for (const [key, entry] of threadDetailSubscriptions) { - if (entry.environmentId === environmentId && !activeThreadIds.has(entry.threadId)) { - disposeThreadDetailSubscriptionByKey(key); - } - } -} - -function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { - clearThreadDetailSubscriptionEviction(entry); - if (!shouldEvictThreadDetailSubscription(entry)) { - return; - } - - entry.evictionTimeoutId = setTimeout(() => { - const currentEntry = threadDetailSubscriptions.get( - getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), - ); - if (!currentEntry) { - return; - } - - currentEntry.evictionTimeoutId = null; - if (!shouldEvictThreadDetailSubscription(currentEntry)) { - return; - } - disposeThreadDetailSubscriptionByKey( - getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), - ); - }, THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS); -} - -function evictIdleThreadDetailSubscriptionsToCapacity(): void { - if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { - return; - } - - const idleEntries = [...threadDetailSubscriptions.entries()] - .filter(([, entry]) => shouldEvictThreadDetailSubscription(entry)) - .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); - - for (const [key] of idleEntries) { - if (threadDetailSubscriptions.size <= MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS) { - return; - } - disposeThreadDetailSubscriptionByKey(key); - } -} - -function reconcileThreadDetailSubscriptionEvictionState( - entry: ThreadDetailSubscriptionEntry, -): void { - clearThreadDetailSubscriptionEviction(entry); - if (!shouldEvictThreadDetailSubscription(entry)) { - return; - } - - scheduleThreadDetailSubscriptionEviction(entry); -} - -function reconcileThreadDetailSubscriptionEvictionForThread( - environmentId: EnvironmentId, - threadId: ThreadId, -): void { - const entry = threadDetailSubscriptions.get( - getThreadDetailSubscriptionKey(environmentId, threadId), - ); - if (!entry) { - return; - } - - reconcileThreadDetailSubscriptionEvictionState(entry); -} - -function reconcileThreadDetailSubscriptionEvictionForEnvironment( - environmentId: EnvironmentId, -): void { - for (const entry of threadDetailSubscriptions.values()) { - if (entry.environmentId === environmentId) { - reconcileThreadDetailSubscriptionEvictionState(entry); - } - } - evictIdleThreadDetailSubscriptionsToCapacity(); -} - -export function retainThreadDetailSubscription( - environmentId: EnvironmentId, - threadId: ThreadId, -): () => void { - const key = getThreadDetailSubscriptionKey(environmentId, threadId); - const existing = threadDetailSubscriptions.get(key); - if (existing) { - clearThreadDetailSubscriptionEviction(existing); - existing.refCount += 1; - existing.lastAccessedAt = Date.now(); - if (!attachThreadDetailSubscription(existing)) { - watchThreadDetailSubscriptionConnection(existing); - } - let released = false; - return () => { - if (released) { - return; - } - released = true; - existing.refCount = Math.max(0, existing.refCount - 1); - existing.lastAccessedAt = Date.now(); - if (existing.refCount === 0) { - reconcileThreadDetailSubscriptionEvictionState(existing); - evictIdleThreadDetailSubscriptionsToCapacity(); - } - }; - } - - const entry: ThreadDetailSubscriptionEntry = { - environmentId, - threadId, - unsubscribe: NOOP, - unsubscribeConnectionListener: null, - refCount: 1, - lastAccessedAt: Date.now(), - evictionTimeoutId: null, - }; - threadDetailSubscriptions.set(key, entry); - if (!attachThreadDetailSubscription(entry)) { - watchThreadDetailSubscriptionConnection(entry); - } - evictIdleThreadDetailSubscriptionsToCapacity(); - - let released = false; - return () => { - if (released) { - return; - } - released = true; - entry.refCount = Math.max(0, entry.refCount - 1); - entry.lastAccessedAt = Date.now(); - if (entry.refCount === 0) { - reconcileThreadDetailSubscriptionEvictionState(entry); - evictIdleThreadDetailSubscriptionsToCapacity(); - } - }; -} - -function emitEnvironmentConnectionRegistryChange() { - for (const listener of environmentConnectionListeners) { - listener(); - } -} - -function emitProviderInvalidation() { - for (const listener of providerInvalidationListeners) { - listener(); - } -} - -function getRuntimeErrorFields(error: unknown) { - return { - lastError: error instanceof Error ? error.message : String(error), - lastErrorAt: new Date().toISOString(), - } as const; -} - -function isoNow(): string { - return new Date().toISOString(); -} - -function readSshHttpErrorStatus(error: unknown): number | null { - if (!(error instanceof Error)) { - return null; - } - - const match = SSH_HTTP_STATUS_RE.exec(error.message); - if (!match) { - return null; - } - - const parsed = Number.parseInt(match[1] ?? "", 10); - return Number.isInteger(parsed) ? parsed : null; -} - -function isSshHttpAuthError(error: unknown, status: number): boolean { - return readSshHttpErrorStatus(error) === status; -} - -function isDesktopSshTargetEqual( - left: DesktopSshEnvironmentTarget | undefined, - right: DesktopSshEnvironmentTarget | undefined, -): boolean { - if (!left || !right) { - return false; - } - - return ( - left.alias === right.alias && - left.hostname === right.hostname && - left.username === right.username && - left.port === right.port - ); -} - -function findSavedEnvironmentRecordByDesktopSshTarget( - target: DesktopSshEnvironmentTarget | undefined, -): SavedEnvironmentRecord | null { - if (!target) { - return null; - } - - return ( - listSavedEnvironmentRecords().find((record) => - isDesktopSshTargetEqual(record.desktopSsh, target), - ) ?? null - ); -} - -function buildSavedEnvironmentRegistryById( - records: ReadonlyArray, -): Record { - return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record< - EnvironmentId, - SavedEnvironmentRecord - >; -} - -type SavedEnvironmentRegistrySnapshot = ReadonlyMap; - -function snapshotSavedEnvironmentRegistry( - environmentIds: ReadonlyArray, -): SavedEnvironmentRegistrySnapshot { - return new Map( - environmentIds.map((environmentId) => [ - environmentId, - getSavedEnvironmentRecord(environmentId) ?? null, - ]), - ); -} - -async function persistSavedEnvironmentRegistryRollback( - snapshot: SavedEnvironmentRegistrySnapshot, -): Promise { - const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); - for (const [environmentId, record] of snapshot) { - if (record) { - byId[environmentId] = record; - continue; - } - delete byId[environmentId]; - } - const records = Object.values(byId); - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), - ); - useSavedEnvironmentRegistryStore.setState({ - byId, - }); -} - -async function resolveDesktopSshEnvironmentBootstrap( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, -): Promise { - const desktopBridge = window.desktopBridge; - if (!desktopBridge) { - throw new Error("SSH launch is only available in the desktop app."); - } - - return await desktopBridge.ensureSshEnvironment(target, options); -} - -function getDesktopSshBridge() { - const desktopBridge = window.desktopBridge; - if (!desktopBridge) { - throw new Error("SSH launch is only available in the desktop app."); - } - return desktopBridge; -} - -async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) { - return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl); -} - -async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) { - return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); -} - -function readIssuedBearerScopes(scope: string): ReadonlyArray { - return decodeIssuedBearerScopes(scope.split(" ")); -} - -async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { - return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); -} - -async function resolveDesktopSshWebSocketConnectionUrl( - wsBaseUrl: string, - httpBaseUrl: string, - bearerToken: string, -) { - const issued = await getDesktopSshBridge().issueSshWebSocketTicket(httpBaseUrl, bearerToken); - const url = new URL(wsBaseUrl, window.location.origin); - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -} - -async function prepareSavedEnvironmentRecordForConnection( - record: SavedEnvironmentRecord, - options?: { readonly issuePairingToken?: boolean }, -): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly pairingToken: string | null; - readonly remotePort: number | null; - readonly remoteServerKind: "external" | "managed" | null; -}> { - if (!record.desktopSsh) { - return { - record, - pairingToken: null, - remotePort: null, - remoteServerKind: null, - }; - } - - const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options); - const nextRecord: SavedEnvironmentRecord = { - ...record, - httpBaseUrl: bootstrap.httpBaseUrl, - wsBaseUrl: bootstrap.wsBaseUrl, - desktopSsh: bootstrap.target, - }; - - if ( - nextRecord.httpBaseUrl !== record.httpBaseUrl || - nextRecord.wsBaseUrl !== record.wsBaseUrl || - !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh) - ) { - await persistSavedEnvironmentRecord(nextRecord); - useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); - } - - return { - record: nextRecord, - pairingToken: bootstrap.pairingToken, - remotePort: bootstrap.remotePort ?? null, - remoteServerKind: bootstrap.remoteServerKind ?? null, - }; -} - -async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly bearerToken: string; - readonly scopes: ReadonlyArray | null; -}> { - const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); - const prepared = await prepareSavedEnvironmentRecordForConnection(record, { - issuePairingToken: true, - }); - if (!prepared.pairingToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Desktop SSH launch did not return a pairing token."); - } - - const bearerSession = await bootstrapDesktopSshBearerSession( - prepared.record.httpBaseUrl, - prepared.pairingToken, - ).catch(async (error) => { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - const detail = [ - `local ${prepared.record.httpBaseUrl}`, - `remote port ${prepared.remotePort ?? "unknown"}`, - prepared.remoteServerKind ? `remote server ${prepared.remoteServerKind}` : null, - ] - .filter(Boolean) - .join(", "); - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${message} (${detail})`); - }); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( - prepared.record.environmentId, - bearerSession.access_token, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } - - return { - record: prepared.record, - bearerToken: bearerSession.access_token, - scopes: readIssuedBearerScopes(bearerSession.scope), - }; -} - -function setRuntimeConnecting(environmentId: EnvironmentId) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connecting", - lastError: null, - lastErrorAt: null, - }); -} - -function setRuntimeConnected(environmentId: EnvironmentId) { - const connectedAt = isoNow(); - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "connected", - authState: "authenticated", - connectedAt, - disconnectedAt: null, - lastError: null, - lastErrorAt: null, - }); - useSavedEnvironmentRegistryStore.getState().markConnected(environmentId, connectedAt); -} - -function setRuntimeDisconnected(environmentId: EnvironmentId, reason?: string | null) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "disconnected", - disconnectedAt: isoNow(), - ...(reason && reason.trim().length > 0 - ? { - lastError: reason, - lastErrorAt: isoNow(), - } - : {}), - }); -} - -function setRuntimeError(environmentId: EnvironmentId, error: unknown) { - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "error", - ...getRuntimeErrorFields(error), - }); -} - -function coalesceOrchestrationUiEvents( - events: ReadonlyArray, -): OrchestrationEvent[] { - if (events.length < 2) { - return [...events]; - } - - const coalesced: OrchestrationEvent[] = []; - for (const event of events) { - const previous = coalesced.at(-1); - if ( - previous?.type === "thread.message-sent" && - event.type === "thread.message-sent" && - previous.payload.threadId === event.payload.threadId && - previous.payload.messageId === event.payload.messageId - ) { - coalesced[coalesced.length - 1] = { - ...event, - payload: { - ...event.payload, - attachments: event.payload.attachments ?? previous.payload.attachments, - createdAt: previous.payload.createdAt, - text: - !event.payload.streaming && event.payload.text.length > 0 - ? event.payload.text - : previous.payload.text + event.payload.text, - }, - }; - continue; - } - - coalesced.push(event); - } - - return coalesced; -} - -function syncProjectUiFromStore() { - const projects = selectProjectsAcrossEnvironments(useStore.getState()); - const clientSettings = getClientSettings(); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: derivePhysicalProjectKey(project), - logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), - cwd: project.cwd, - })), - ); -} - -function syncThreadUiFromStore() { - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncThreads( - threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - seedVisitedAt: thread.updatedAt ?? thread.createdAt, - })), - ); - markPromotedDraftThreadsByRef( - threads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), - ); -} - -function reconcileSnapshotDerivedState() { - syncProjectUiFromStore(); - syncThreadUiFromStore(); - - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - const activeThreadKeys = collectActiveTerminalUiThreadKeys({ - snapshotThreads: threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - deletedAt: null, - archivedAt: thread.archivedAt, - })), - draftThreadKeys: useComposerDraftStore.getState().listDraftThreadKeys(), - }); - useTerminalUiStateStore.getState().removeOrphanedTerminalUiStates(activeThreadKeys); -} - -function applyRecoveredEventBatch( - events: ReadonlyArray, - environmentId: EnvironmentId, -) { - if (events.length === 0) { - return; - } - - const batchEffects = deriveOrchestrationBatchEffects(events); - const uiEvents = coalesceOrchestrationUiEvents(events); - const needsProjectUiSync = events.some( - (event) => - event.type === "project.created" || - event.type === "project.meta-updated" || - event.type === "project.deleted", - ); - - if (batchEffects.needsProviderInvalidation) { - needsProviderInvalidation = true; - void activeService?.queryInvalidationThrottler.maybeExecute(); - } - - useStore.getState().applyOrchestrationEvents(uiEvents, environmentId); - if (needsProjectUiSync) { - const projects = selectProjectsAcrossEnvironments(useStore.getState()); - const clientSettings = getClientSettings(); - useUiStateStore.getState().syncProjects( - projects.map((project) => ({ - key: derivePhysicalProjectKey(project), - logicalKey: deriveLogicalProjectKeyFromSettings(project, clientSettings), - cwd: project.cwd, - })), - ); - } - - const needsThreadUiSync = events.some( - (event) => event.type === "thread.created" || event.type === "thread.deleted", - ); - if (needsThreadUiSync) { - const threads = selectThreadsAcrossEnvironments(useStore.getState()); - useUiStateStore.getState().syncThreads( - threads.map((thread) => ({ - key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - seedVisitedAt: thread.updatedAt ?? thread.createdAt, - })), - ); - } - - const draftStore = useComposerDraftStore.getState(); - for (const threadId of batchEffects.promoteDraftThreadIds) { - markPromotedDraftThreadByRef(scopeThreadRef(environmentId, threadId)); - } - for (const threadId of batchEffects.clearDeletedThreadIds) { - draftStore.clearDraftThread(scopeThreadRef(environmentId, threadId)); - useUiStateStore - .getState() - .clearThreadUi(scopedThreadKey(scopeThreadRef(environmentId, threadId))); - } - for (const event of events) { - if (event.type === "project.deleted") { - draftStore.clearProjectDraftThreadId(scopeProjectRef(environmentId, event.payload.projectId)); - } - } - for (const threadId of batchEffects.removeTerminalUiStateThreadIds) { - useTerminalUiStateStore - .getState() - .removeTerminalUiState(scopeThreadRef(environmentId, threadId)); - } - - reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); -} - -export function applyEnvironmentThreadDetailEvent( - event: OrchestrationEvent, - environmentId: EnvironmentId, -) { - applyRecoveredEventBatch([event], environmentId); -} - -function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) { - if ( - !shouldApplyProjectionEvent({ - current: readLastAppliedProjectionVersion(environmentId), - sequence: event.sequence, - }) - ) { - return; - } - - const threadId = - event.kind === "thread-upserted" - ? event.thread.id - : event.kind === "thread-removed" - ? event.threadId - : null; - const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; - const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; - - useStore.getState().applyShellEvent(event, environmentId); - markAppliedProjectionEvent(environmentId, event.sequence); - - switch (event.kind) { - case "project-upserted": - case "project-removed": - syncProjectUiFromStore(); - return; - case "thread-upserted": - syncThreadUiFromStore(); - if (!previousThread && threadRef) { - markPromotedDraftThreadByRef(threadRef); - } - if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { - useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); - } - reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); - evictIdleThreadDetailSubscriptionsToCapacity(); - return; - case "thread-removed": - if (threadRef) { - disposeThreadDetailSubscriptionByKey(scopedThreadKey(threadRef)); - useComposerDraftStore.getState().clearDraftThread(threadRef); - useUiStateStore.getState().clearThreadUi(scopedThreadKey(threadRef)); - useTerminalUiStateStore.getState().removeTerminalUiState(threadRef); - } - syncThreadUiFromStore(); - return; - } -} - -function createEnvironmentConnectionHandlers() { - return { - applyShellEvent, - syncShellSnapshot: (snapshot: OrchestrationShellSnapshot, environmentId: EnvironmentId) => { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Shell snapshots already have createShellSnapshotManager in - // @t3tools/client-runtime. Web currently projects snapshots straight into - // its denormalized Zustand store; future shell changes should migrate or - // bridge to the shared manager instead of growing this handler. - if ( - !shouldApplyProjectionSnapshot({ - current: readLastAppliedProjectionVersion(environmentId), - next: snapshot, - }) - ) { - return; - } - - useStore.getState().syncServerShellSnapshot(snapshot, environmentId); - markAppliedProjectionSnapshot(environmentId, snapshot); - reconcileThreadDetailSubscriptionsForEnvironment( - environmentId, - snapshot.threads.map((thread) => thread.id), - ); - reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); - reconcileSnapshotDerivedState(); - }, - }; -} - -function createWsRpcClient(transport: WsTransport): WsRpcClient { - return createBaseWsRpcClient(transport, { - beforeReconnect: () => resetWsReconnectBackoff(), - }); -} - -function createPrimaryEnvironmentClient( - knownEnvironment: ReturnType, -) { - const wsBaseUrl = getKnownEnvironmentWsBaseUrl(knownEnvironment); - if (!wsBaseUrl) { - throw new Error( - `Unable to resolve websocket URL for ${knownEnvironment?.label ?? "primary environment"}.`, - ); - } - const connectionLabel = knownEnvironment?.label ?? null; - - return createWsRpcClient( - new WsTransport(wsBaseUrl, { - getConnectionLabel: () => connectionLabel, - getVersionMismatchHint: () => - resolveServerConfigVersionMismatch(getServerConfig())?.hint ?? null, - }), - ); -} - -function createSavedEnvironmentClient( - environmentId: EnvironmentId, - credentialRef: { current: SavedEnvironmentCredential }, - relayTraceHeadersRef: { current: Headers.Headers | null }, -): WsRpcClient { - useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); - - return createWsRpcClient( - new WsTransport( - async () => { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error(`Saved environment ${environmentId} not found.`); - } - const credential = credentialRef.current; - if (record.desktopSsh) { - if (credential.method !== "bearer") { - throw new Error("SSH environments require bearer credentials."); - } - return await resolveDesktopSshWebSocketConnectionUrl( - record.wsBaseUrl, - record.httpBaseUrl, - credential.token, - ); - } - if (credential.method === "dpop") { - try { - const relayTraceHeaders = relayTraceHeadersRef.current; - relayTraceHeadersRef.current = null; - return await webRuntime.runPromise( - resolveManagedRelayWebSocketUrl(record, credential, relayTraceHeaders), - ); - } catch (error) { - if (!isEnvironmentAuthInvalidError(error)) { - throw error; - } - const renewed = await renewManagedRelayCredential(record); - if (!renewed || renewed.credential.method !== "dpop") { - throw error; - } - const renewedCredential = renewed.credential; - credentialRef.current = renewedCredential; - return await webRuntime.runPromise( - resolveManagedRelayWebSocketUrl( - renewed.record, - renewedCredential, - renewed.relayTraceHeaders, - ), - ); - } - } - return await webRuntime.runPromise( - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - bearerToken: credential.token, - }), - ); - }, - { - getConnectionLabel: () => getSavedEnvironmentRecord(environmentId)?.label ?? null, - getVersionMismatchHint: () => - resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - )?.hint ?? null, - onAttempt: () => { - setRuntimeConnecting(environmentId); - }, - onOpen: () => { - setRuntimeConnected(environmentId); - }, - onError: (message: string) => { - const mismatch = resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - ); - useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { - connectionState: "error", - lastError: appendVersionMismatchHint(message, mismatch), - lastErrorAt: isoNow(), - }); - }, - onClose: (details: { readonly code: number; readonly reason: string }) => { - setRuntimeDisconnected( - environmentId, - appendVersionMismatchHint( - details.reason, - resolveServerConfigVersionMismatch( - useSavedEnvironmentRuntimeStore.getState().byId[environmentId]?.serverConfig, - ), - ), - ); - }, - }, - ), - ); -} - -async function refreshSavedEnvironmentMetadata( - environmentId: EnvironmentId, - credential: SavedEnvironmentCredential, - client: WsRpcClient, - scopeHint?: ReadonlyArray | null, - configHint?: ServerConfig | null, -): Promise { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error(`Saved environment ${environmentId} not found.`); - } - - const [serverConfig, sessionState] = await Promise.all([ - configHint ? Promise.resolve(configHint) : client.server.getConfig(), - record.desktopSsh - ? credential.method === "bearer" - ? fetchDesktopSshSessionState(record.httpBaseUrl, credential.token) - : Promise.reject(new Error("SSH environments require bearer credentials.")) - : credential.method === "dpop" - ? webRuntime.runPromise( - createManagedRelayDpopProof({ - method: "GET", - url: new URL("/api/auth/session", record.httpBaseUrl).toString(), - accessToken: credential.accessToken, - }).pipe( - Effect.flatMap((proof) => - fetchRemoteDpopSessionState({ - httpBaseUrl: record.httpBaseUrl, - accessToken: credential.accessToken, - dpopProof: proof, - }), - ), - ), - ) - : webRuntime.runPromise( - fetchRemoteSessionState({ - httpBaseUrl: record.httpBaseUrl, - bearerToken: credential.token, - }), - ), - ]); - - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: sessionState.authenticated ? "authenticated" : "requires-auth", - descriptor: serverConfig.environment, - serverConfig, - scopes: sessionState.authenticated ? (sessionState.scopes ?? scopeHint ?? null) : null, - }); - useSavedEnvironmentRegistryStore - .getState() - .rename(record.environmentId, serverConfig.environment.label); -} - -const resolveManagedRelayWebSocketUrl = Effect.fn( - "web.environment.resolveManagedRelayWebSocketUrl", -)(function* ( - record: SavedEnvironmentRecord, - credential: Extract, - traceHeaders: Headers.Headers | null, -) { - const request = createManagedRelayDpopProof({ - method: "POST", - url: new URL("/api/auth/websocket-ticket", record.httpBaseUrl).toString(), - accessToken: credential.accessToken, - }).pipe( - Effect.flatMap((proof) => - resolveRemoteDpopWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - accessToken: credential.accessToken, - dpopProof: proof, - }), - ), - ); - const parent = traceHeaders ? HttpTraceContext.fromHeaders(traceHeaders) : Option.none(); - return yield* ( - Option.isSome(parent) - ? request.pipe(Effect.withParentSpan(parent.value)) - : request.pipe( - Effect.withSpan("relay.environment.reconnect", { - root: true, - attributes: { "relay.environment_id": record.environmentId }, - }), - ) - ).pipe(withRelayClientTracing); -}); - -async function renewManagedRelayCredential(record: SavedEnvironmentRecord): Promise<{ - readonly record: SavedEnvironmentRecord; - readonly credential: SavedEnvironmentCredential; - readonly relayTraceHeaders: Headers.Headers; -} | null> { - if (!record.relayManaged) { - return null; - } - const clerkToken = await readManagedRelayClerkToken(); - if (!clerkToken) { - return null; - } - const connected = await webRuntime.runPromise( - connectManagedCloudEnvironment({ - clerkToken, - relayUrl: record.relayManaged.relayUrl, - environment: { - environmentId: record.environmentId, - label: record.label, - linkedAt: record.createdAt, - endpoint: { - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - providerKind: "cloudflare_tunnel", - }, - }, - }), - ); - const nextRecord: SavedEnvironmentRecord = { - ...record, - label: connected.label, - httpBaseUrl: connected.httpBaseUrl, - wsBaseUrl: connected.wsBaseUrl, - }; - const credential: SavedEnvironmentCredential = { - version: 1, - method: "dpop", - accessToken: connected.accessToken, - }; - await persistSavedEnvironmentRecord(nextRecord); - if (!(await writeSavedEnvironmentCredential(nextRecord.environmentId, credential))) { - throw new Error("Unable to persist refreshed managed environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); - return { record: nextRecord, credential, relayTraceHeaders: connected.relayTraceHeaders }; -} - -function registerConnection(connection: EnvironmentConnection): EnvironmentConnection { - const existing = environmentConnections.get(connection.environmentId); - if (existing && existing !== connection) { - throw new Error(`Environment ${connection.environmentId} already has an active connection.`); - } - environmentConnections.set(connection.environmentId, connection); - terminalMetadataSubscriptions.get(connection.environmentId)?.(); - terminalMetadataSubscriptions.set( - connection.environmentId, - subscribeTerminalMetadata({ - environmentId: connection.environmentId, - client: connection.client, - }), - ); - attachThreadDetailSubscriptionsForEnvironment(connection.environmentId); - emitEnvironmentConnectionRegistryChange(); - return connection; -} - -async function removeConnection(environmentId: EnvironmentId): Promise { - const connection = environmentConnections.get(environmentId); - if (!connection) { - return false; - } - - lastAppliedProjectionVersionByEnvironment.delete(environmentId); - environmentConnections.delete(environmentId); - terminalMetadataSubscriptions.get(environmentId)?.(); - terminalMetadataSubscriptions.delete(environmentId); - terminalSessionManager.invalidateEnvironment(environmentId); - emitEnvironmentConnectionRegistryChange(); - detachThreadDetailSubscriptionsForEnvironment(environmentId); - await connection.dispose(); - return true; -} - -function createPrimaryEnvironmentConnection(): EnvironmentConnection { - const knownEnvironment = getPrimaryKnownEnvironment(); - if (!knownEnvironment?.environmentId) { - throw new Error("Unable to resolve the primary environment."); - } - - const existing = environmentConnections.get(knownEnvironment.environmentId); - if (existing) { - return existing; - } - - return registerConnection( - createEnvironmentConnection({ - kind: "primary", - knownEnvironment, - client: createPrimaryEnvironmentClient(knownEnvironment), - ...createEnvironmentConnectionHandlers(), - }), - ); -} - -function maybeCreatePrimaryEnvironmentConnection(): EnvironmentConnection | null { - return getPrimaryKnownEnvironment()?.environmentId ? createPrimaryEnvironmentConnection() : null; -} - -async function ensureSavedEnvironmentConnection( - record: SavedEnvironmentRecord, - options?: { - readonly client?: WsRpcClient; - readonly bearerToken?: string; - readonly credential?: SavedEnvironmentCredential; - readonly scopes?: ReadonlyArray | null; - readonly serverConfig?: ServerConfig | null; - readonly allowManagedRenewal?: boolean; - readonly relayTraceHeaders?: Headers.Headers; - }, -): Promise { - const existing = environmentConnections.get(record.environmentId); - if (existing) { - return existing; - } - - const pending = pendingSavedEnvironmentConnections.get(record.environmentId); - if (pending) { - return pending.promise; - } - - const attempt = savedEnvironmentConnectionAttempts.begin(record.environmentId); - const pendingEntry: PendingSavedEnvironmentConnection = { - isCurrent: attempt.isCurrent, - promise: Promise.resolve().then(async () => { - let activeRecord = record; - let scopeHint = options?.scopes ?? null; - let credential = - options?.credential ?? - (options?.bearerToken - ? ({ version: 1, method: "bearer", token: options.bearerToken } as const) - : await readSavedEnvironmentCredential(record.environmentId)); - if (!credential) { - if (record.desktopSsh) { - const issued = await issueDesktopSshBearerSession(record); - activeRecord = issued.record; - credential = { version: 1, method: "bearer", token: issued.bearerToken }; - scopeHint = issued.scopes; - } else { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: "requires-auth", - scopes: null, - connectionState: "disconnected", - lastError: "Saved environment is missing its saved credential. Pair it again.", - lastErrorAt: isoNow(), - }); - throw new Error("Saved environment is missing its saved credential."); - } - } else { - const prepared = await prepareSavedEnvironmentRecordForConnection(record); - activeRecord = prepared.record; - } - - const activeCredential = { current: credential }; - const relayTraceHeaders = { current: options?.relayTraceHeaders ?? null }; - const client = - options?.client ?? - createSavedEnvironmentClient( - activeRecord.environmentId, - activeCredential, - relayTraceHeaders, - ); - const initialConfigSnapshot = createDeferredPromise(); - const knownEnvironment = createKnownEnvironment({ - id: activeRecord.environmentId, - label: activeRecord.label, - source: "manual", - target: { - httpBaseUrl: activeRecord.httpBaseUrl, - wsBaseUrl: activeRecord.wsBaseUrl, - }, - }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...knownEnvironment, - environmentId: activeRecord.environmentId, - }, - client, - refreshMetadata: async () => { - await refreshSavedEnvironmentMetadata( - activeRecord.environmentId, - activeCredential.current, - client, - ); - }, - onConfigSnapshot: (config) => { - initialConfigSnapshot.resolve(config); - useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { - descriptor: config.environment, - serverConfig: config, - }); - }, - onWelcome: (payload) => { - useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { - descriptor: payload.environment, - }); - }, - ...createEnvironmentConnectionHandlers(), - }); - - try { - try { - const initialServerConfig = - options?.serverConfig ?? - (await waitForConfigSnapshot( - initialConfigSnapshot.promise, - INITIAL_SERVER_CONFIG_SNAPSHOT_WAIT_MS, - )); - await refreshSavedEnvironmentMetadata( - activeRecord.environmentId, - activeCredential.current, - client, - scopeHint, - initialServerConfig, - ); - } catch (error) { - const isAuthError = activeRecord.desktopSsh - ? isSshHttpAuthError(error, 401) - : isEnvironmentAuthInvalidError(error); - if (!isAuthError) { - throw error; - } - if (!activeRecord.desktopSsh) { - if ( - activeCredential.current.method === "dpop" && - options?.allowManagedRenewal !== false - ) { - const renewed = await renewManagedRelayCredential(activeRecord); - if (renewed) { - await connection.dispose().catch(() => undefined); - pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); - return await ensureSavedEnvironmentConnection(renewed.record, { - credential: renewed.credential, - scopes: scopeHint, - serverConfig: options?.serverConfig ?? null, - allowManagedRenewal: false, - }); - } - } - await removeSavedEnvironmentBearerToken(activeRecord.environmentId); - throw new Error( - activeCredential.current.method === "dpop" - ? "Managed tunnel credential expired. Connect it again from T3 Connect." - : "Saved environment credential expired. Pair it again.", - { - cause: error, - }, - ); - } - - const issued = await issueDesktopSshBearerSession(activeRecord); - activeRecord = issued.record; - credential = { version: 1, method: "bearer", token: issued.bearerToken }; - scopeHint = issued.scopes; - await connection.dispose().catch(() => undefined); - pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); - return await ensureSavedEnvironmentConnection(activeRecord, { - credential, - scopes: scopeHint, - serverConfig: options?.serverConfig ?? null, - }); - } - if ( - !pendingEntry.isCurrent() || - pendingSavedEnvironmentConnections.get(activeRecord.environmentId) !== pendingEntry - ) { - await connection.dispose().catch(() => undefined); - throw new EnvironmentConnectionAttemptCancelledError(activeRecord.environmentId); - } - registerConnection(connection); - return connection; - } catch (error) { - if (error instanceof EnvironmentConnectionAttemptCancelledError) { - throw error; - } - setRuntimeError(activeRecord.environmentId, error); - const removed = await removeConnection(activeRecord.environmentId).catch(() => false); - if (!removed) { - await connection.dispose().catch(() => undefined); - } - throw error; - } - }), - }; - - pendingSavedEnvironmentConnections.set(record.environmentId, pendingEntry); - return await pendingEntry.promise.finally(() => { - if (pendingSavedEnvironmentConnections.get(record.environmentId) === pendingEntry) { - pendingSavedEnvironmentConnections.delete(record.environmentId); - savedEnvironmentConnectionAttempts.cancel(record.environmentId); - } - }); -} - -async function syncSavedEnvironmentConnections( - records: ReadonlyArray, -): Promise { - const expectedEnvironmentIds = new Set(records.map((record) => record.environmentId)); - const staleEnvironmentIds: EnvironmentId[] = []; - for (const connection of environmentConnections.values()) { - if (connection.kind !== "saved") continue; - if (expectedEnvironmentIds.has(connection.environmentId)) continue; - staleEnvironmentIds.push(connection.environmentId); - } - - await Promise.all( - staleEnvironmentIds.map((environmentId) => disconnectSavedEnvironment(environmentId)), - ); - await Promise.all( - records.map((record) => ensureSavedEnvironmentConnection(record).catch(() => undefined)), - ); -} - -function stopActiveService() { - activeService?.stop(); - activeService = null; -} - -function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void { - const now = Date.now(); - if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) { - return; - } - - for (const connection of environmentConnections.values()) { - if (connection.client.isHeartbeatFresh()) { - continue; - } - lastBrowserResumeReconnectAt = now; - void connection.reconnect().catch((error) => { - console.warn("Environment reconnect after browser resume failed", { - environmentId: connection.environmentId, - reason, - error: error instanceof Error ? error.message : String(error), - }); - }); - } -} - -function subscribeBrowserResumeReconnects(): () => void { - if (typeof document === "undefined" || typeof window === "undefined") { - return NOOP; - } - - const handleVisibilityChange = () => { - if (document.visibilityState === "hidden") { - lastBrowserHiddenAt = Date.now(); - return; - } - if (document.visibilityState === "visible" && lastBrowserHiddenAt !== null) { - lastBrowserHiddenAt = null; - reconnectEnvironmentConnectionsAfterBrowserResume("visibilitychange"); - } - }; - - const handlePageShow = (event: PageTransitionEvent) => { - if (event.persisted || lastBrowserHiddenAt !== null) { - lastBrowserHiddenAt = null; - reconnectEnvironmentConnectionsAfterBrowserResume("pageshow"); - } - }; - - document.addEventListener("visibilitychange", handleVisibilityChange); - window.addEventListener("pageshow", handlePageShow); - return () => { - document.removeEventListener("visibilitychange", handleVisibilityChange); - window.removeEventListener("pageshow", handlePageShow); - }; -} - -export function subscribeEnvironmentConnections(listener: () => void): () => void { - environmentConnectionListeners.add(listener); - return () => { - environmentConnectionListeners.delete(listener); - }; -} - -export function subscribeProviderInvalidations(listener: () => void): () => void { - providerInvalidationListeners.add(listener); - return () => { - providerInvalidationListeners.delete(listener); - }; -} - -export function listEnvironmentConnections(): ReadonlyArray { - return [...environmentConnections.values()]; -} - -export function readEnvironmentConnection( - environmentId: EnvironmentId, -): EnvironmentConnection | null { - return environmentConnections.get(environmentId) ?? null; -} - -export function requireEnvironmentConnection(environmentId: EnvironmentId): EnvironmentConnection { - const connection = readEnvironmentConnection(environmentId); - if (!connection) { - throw new Error(`No websocket client registered for environment ${environmentId}.`); - } - return connection; -} - -export function getPrimaryEnvironmentConnection(): EnvironmentConnection { - return createPrimaryEnvironmentConnection(); -} - -export async function disconnectSavedEnvironment(environmentId: EnvironmentId): Promise { - const record = getSavedEnvironmentRecord(environmentId); - const pendingConnection = pendingSavedEnvironmentConnections.get(environmentId); - if (pendingConnection) { - savedEnvironmentConnectionAttempts.cancel(environmentId); - pendingSavedEnvironmentConnections.delete(environmentId); - } - const connection = environmentConnections.get(environmentId); - - if (connection?.kind === "saved") { - await removeConnection(environmentId).catch(() => false); - } - setRuntimeDisconnected(environmentId); - - if (record?.desktopSsh && typeof window !== "undefined") { - await window.desktopBridge?.disconnectSshEnvironment(record.desktopSsh); - await removeSavedEnvironmentBearerToken(environmentId); - } -} - -export async function reconnectSavedEnvironment(environmentId: EnvironmentId): Promise { - const record = getSavedEnvironmentRecord(environmentId); - if (!record) { - throw new Error("Saved environment not found."); - } - - const connection = environmentConnections.get(environmentId); - if (!connection) { - setRuntimeConnecting(environmentId); - try { - await ensureSavedEnvironmentConnection(record); - return; - } catch (error) { - if (isSavedEnvironmentConnectionCancelledError(error)) { - return; - } - setRuntimeError(environmentId, error); - throw error; - } - } - - setRuntimeConnecting(environmentId); - try { - if (record.desktopSsh) { - await prepareSavedEnvironmentRecordForConnection(record); - } - await connection.reconnect(); - } catch (error) { - if (record.desktopSsh) { - try { - const issued = await issueDesktopSshBearerSession( - getSavedEnvironmentRecord(environmentId) ?? record, - ); - await removeConnection(environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(issued.record, { - bearerToken: issued.bearerToken, - scopes: issued.scopes, - }); - return; - } catch (recoveryError) { - if (isSavedEnvironmentConnectionCancelledError(recoveryError)) { - return; - } - setRuntimeError(environmentId, recoveryError); - throw recoveryError; - } - } - setRuntimeError(environmentId, error); - throw error; - } -} - -export async function removeSavedEnvironment(environmentId: EnvironmentId): Promise { - await disconnectSavedEnvironment(environmentId); - disposeThreadDetailSubscriptionsForEnvironment(environmentId); - useSavedEnvironmentRegistryStore.getState().remove(environmentId); - useSavedEnvironmentRuntimeStore.getState().clear(environmentId); - useStore.getState().removeEnvironmentState(environmentId); - await removeSavedEnvironmentBearerToken(environmentId); -} - -export async function addSavedEnvironment(input: { - readonly label: string; - readonly pairingUrl?: string; - readonly host?: string; - readonly pairingCode?: string; - readonly desktopSsh?: DesktopSshEnvironmentTarget; -}): Promise { - const resolvedTarget = resolveRemotePairingTarget({ - ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), - ...(input.host !== undefined ? { host: input.host } : {}), - ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), - }); - const descriptor = input.desktopSsh - ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) - : await webRuntime.runPromise( - fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - }), - ); - const environmentId = descriptor.environmentId; - const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); - const existingRecord = - getSavedEnvironmentRecord(environmentId) ?? - findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); - const staleDesktopSshRecord = - existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null; - - const bearerSession = input.desktopSsh - ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) - : await webRuntime.runPromise( - bootstrapRemoteBearerSession({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - credential: resolvedTarget.credential, - }), - ); - - const record: SavedEnvironmentRecord = { - environmentId, - label: input.label.trim() || existingRecord?.label || descriptor.label, - wsBaseUrl: resolvedTarget.wsBaseUrl, - httpBaseUrl: resolvedTarget.httpBaseUrl, - createdAt: existingRecord?.createdAt ?? isoNow(), - lastConnectedAt: isoNow(), - ...((input.desktopSsh ?? existingRecord?.desktopSsh) - ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh } - : {}), - }; - - await persistSavedEnvironmentRecord(record); - const didPersistBearerToken = await writeSavedEnvironmentBearerToken( - environmentId, - bearerSession.access_token, - ); - if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(registrySnapshot); - throw new Error("Unable to persist saved environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(record); - if (staleDesktopSshRecord) { - await removeSavedEnvironment(staleDesktopSshRecord.environmentId); - } - await removeConnection(environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(record, { - bearerToken: bearerSession.access_token, - scopes: readIssuedBearerScopes(bearerSession.scope), - }); - return record; -} - -export async function addManagedRelayEnvironment(input: { - readonly environmentId: EnvironmentId; - readonly label: string; - readonly httpBaseUrl: string; - readonly wsBaseUrl: string; - readonly relayUrl: string; - readonly accessToken: string; - readonly relayTraceHeaders: Headers.Headers; -}): Promise { - const existingRecord = getSavedEnvironmentRecord(input.environmentId); - const record: SavedEnvironmentRecord = { - environmentId: input.environmentId, - label: input.label.trim() || existingRecord?.label || "Managed environment", - httpBaseUrl: input.httpBaseUrl, - wsBaseUrl: input.wsBaseUrl, - createdAt: existingRecord?.createdAt ?? isoNow(), - lastConnectedAt: isoNow(), - relayManaged: { relayUrl: input.relayUrl }, - }; - const credential: SavedEnvironmentCredential = { - version: 1, - method: "dpop", - accessToken: input.accessToken, - }; - - await persistSavedEnvironmentRecord(record); - if (!(await writeSavedEnvironmentCredential(record.environmentId, credential))) { - throw new Error("Unable to persist managed environment credentials."); - } - useSavedEnvironmentRegistryStore.getState().upsert(record); - await removeConnection(record.environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(record, { - credential, - relayTraceHeaders: input.relayTraceHeaders, - }); - return record; -} - -export async function connectDesktopSshEnvironment( - target: DesktopSshEnvironmentTarget, - options?: { label?: string }, -): Promise { - const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, { - issuePairingToken: true, - }); - if (!bootstrap.pairingToken) { - throw new Error("Desktop SSH launch did not return a pairing token."); - } - - return await addSavedEnvironment({ - label: options?.label?.trim() || bootstrap.target.alias, - host: bootstrap.httpBaseUrl, - pairingCode: bootstrap.pairingToken, - desktopSsh: bootstrap.target, - }).catch((error) => { - const detail = [ - `local ${bootstrap.httpBaseUrl}`, - `remote port ${bootstrap.remotePort ?? "unknown"}`, - bootstrap.remoteServerKind ? `remote server ${bootstrap.remoteServerKind}` : null, - ] - .filter(Boolean) - .join(", "); - const message = error instanceof Error ? error.message : String(error); - throw new Error(`${message} (${detail})`); - }); -} - -export async function ensureEnvironmentConnectionBootstrapped( - environmentId: EnvironmentId, -): Promise { - await environmentConnections.get(environmentId)?.ensureBootstrapped(); -} - -export function startEnvironmentConnectionService(queryClient: QueryClient): () => void { - if (activeService?.queryClient === queryClient) { - activeService.refCount += 1; - return () => { - if (!activeService || activeService.queryClient !== queryClient) { - return; - } - activeService.refCount -= 1; - if (activeService.refCount === 0) { - stopActiveService(); - } - }; - } - - stopActiveService(); - needsProviderInvalidation = false; - const queryInvalidationThrottler = new Throttler( - () => { - if (!needsProviderInvalidation) { - return; - } - needsProviderInvalidation = false; - emitProviderInvalidation(); - }, - { - wait: 100, - leading: false, - trailing: true, - }, - ); - const requestSavedEnvironmentSync = createSavedEnvironmentSyncScheduler(); - - maybeCreatePrimaryEnvironmentConnection(); - - const unsubscribeSavedEnvironments = useSavedEnvironmentRegistryStore.subscribe(() => { - if (!hasSavedEnvironmentRegistryHydrated()) { - return; - } - void requestSavedEnvironmentSync(); - }); - - void waitForSavedEnvironmentRegistryHydration() - .then(() => requestSavedEnvironmentSync()) - .catch(() => undefined); - - const unsubscribeBrowserResumeReconnects = subscribeBrowserResumeReconnects(); - - activeService = { - queryClient, - queryInvalidationThrottler, - refCount: 1, - stop: () => { - unsubscribeSavedEnvironments(); - unsubscribeBrowserResumeReconnects(); - queryInvalidationThrottler.cancel(); - }, - }; - - return () => { - if (!activeService || activeService.queryClient !== queryClient) { - return; - } - activeService.refCount -= 1; - if (activeService.refCount === 0) { - stopActiveService(); - } - }; -} - -export async function resetEnvironmentServiceForTests(): Promise { - stopActiveService(); - lastBrowserHiddenAt = null; - lastBrowserResumeReconnectAt = Number.NEGATIVE_INFINITY; - lastAppliedProjectionVersionByEnvironment.clear(); - pendingSavedEnvironmentConnections.clear(); - savedEnvironmentConnectionAttempts.clear(); - for (const key of Array.from(threadDetailSubscriptions.keys())) { - disposeThreadDetailSubscriptionByKey(key); - } - for (const unsubscribe of terminalMetadataSubscriptions.values()) { - unsubscribe(); - } - terminalMetadataSubscriptions.clear(); - terminalSessionManager.reset(); - await Promise.all( - [...environmentConnections.keys()].map((environmentId) => removeConnection(environmentId)), - ); -} diff --git a/apps/web/src/historyBootstrap.test.ts b/apps/web/src/historyBootstrap.test.ts index 2ccdb66016d..b4be13716ea 100644 --- a/apps/web/src/historyBootstrap.test.ts +++ b/apps/web/src/historyBootstrap.test.ts @@ -14,6 +14,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "hello", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, { @@ -21,6 +23,8 @@ describe("buildBootstrapInput", () => { role: "assistant", text: "world", createdAt: "2026-02-09T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:01.000Z", streaming: false, }, ], @@ -45,6 +49,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "first question with details", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, { @@ -52,6 +58,8 @@ describe("buildBootstrapInput", () => { role: "assistant", text: "first answer with details", createdAt: "2026-02-09T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:01.000Z", streaming: false, }, { @@ -59,6 +67,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "second question with details", createdAt: "2026-02-09T00:00:02.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:02.000Z", streaming: false, }, ], @@ -82,6 +92,8 @@ describe("buildBootstrapInput", () => { role: "user", text: "old context", createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, ], @@ -112,6 +124,8 @@ describe("buildBootstrapInput", () => { }, ], createdAt: "2026-02-09T00:00:00.000Z", + turnId: null, + updatedAt: "2026-02-09T00:00:00.000Z", streaming: false, }, ], diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e440497ba42..83073380e5d 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,8 +1,7 @@ -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef } from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; -import { useShallow } from "zustand/react/shallow"; import { type DraftThreadEnvMode, type DraftThreadState, @@ -15,14 +14,13 @@ import { getProjectOrderKey, selectProjectGroupingSettings, } from "../logicalProject"; -import { selectProjectsAcrossEnvironments, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; +import { useProjects, useThreadDetail } from "../state/entities"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { useUiStateStore } from "../uiStateStore"; import { useSettings } from "./useSettings"; function useNewThreadState() { - const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projects = useProjects(); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); const router = useRouter(); const getCurrentRouteTarget = useCallback(() => { @@ -154,9 +152,7 @@ export function useHandleNewThread() { select: (params) => resolveThreadRouteTarget(params), }); const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; - const activeThread = useStore( - useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), - ); + const activeThread = useThreadDetail(routeThreadRef); const getDraftThread = useComposerDraftStore((store) => store.getDraftThread); const activeDraftThread = useComposerDraftStore(() => routeTarget @@ -165,7 +161,7 @@ export function useHandleNewThread() { : useComposerDraftStore.getState().getDraftSession(routeTarget.draftId) : null, ); - const projects = useStore(useShallow((store) => selectProjectsAcrossEnvironments(store))); + const projects = useProjects(); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts new file mode 100644 index 00000000000..3552c82d9dc --- /dev/null +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -0,0 +1,176 @@ +import * as Schema from "effect/Schema"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; + +const WidthSchema = Schema.Finite; + +export interface UseResizableWidthOptions { + /** localStorage key the persisted width is stored under. */ + readonly storageKey: string; + readonly defaultWidth: number; + readonly minWidth: number; + readonly maxWidth: number; + /** + * Which edge of the host element carries the drag handle: + * - "left" → panel grows leftward (right-anchored panels) + * - "right" → panel grows rightward (left-anchored panels) + */ + readonly edge: "left" | "right"; +} + +export interface ResizableWidthHandlers { + readonly onPointerDown: (event: ReactPointerEvent) => void; + readonly onPointerMove: (event: ReactPointerEvent) => void; + readonly onPointerUp: (event: ReactPointerEvent) => void; + readonly onPointerCancel: (event: ReactPointerEvent) => void; +} + +/** + * Width state for a side-anchored panel resized via a drag handle on the + * specified edge. Width is read from localStorage on mount and persisted on + * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec). + * + * The hook updates an internal `width` state during drag (so the panel + * follows the cursor live) and only commits to localStorage when the user + * lifts the pointer. + */ +export function useResizableWidth(options: UseResizableWidthOptions): { + readonly width: number; + readonly handlers: ResizableWidthHandlers; +} { + const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; + + const clamp = useCallback( + (value: number): number => { + if (!Number.isFinite(value)) return defaultWidth; + return Math.max(minWidth, Math.min(maxWidth, value)); + }, + [defaultWidth, maxWidth, minWidth], + ); + + // No cross-tab subscription: panel width is per-window state. + const [width, setWidth] = useState(() => { + if (typeof window === "undefined") return defaultWidth; + try { + const stored = getLocalStorageItem(storageKey, WidthSchema); + return clamp(stored ?? defaultWidth); + } catch { + return defaultWidth; + } + }); + + // Re-clamp if min/max change at runtime (e.g. window resize narrows max). + useEffect(() => { + setWidth((current) => clamp(current)); + }, [clamp]); + + const dragStateRef = useRef<{ + pointerId: number; + startX: number; + startWidth: number; + pending: number; + rafId: number | null; + target: HTMLElement; + } | null>(null); + + const releasePointer = useCallback((pointerId: number) => { + const state = dragStateRef.current; + if (!state) return; + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId); + } + try { + if (state.target.hasPointerCapture(pointerId)) { + state.target.releasePointerCapture(pointerId); + } + } catch { + // pointer may already be released; harmless. + } + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + dragStateRef.current = null; + }, []); + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + const target = event.currentTarget; + try { + target.setPointerCapture(event.pointerId); + } catch { + return; + } + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: width, + pending: width, + rafId: null, + target, + }; + }, + [width], + ); + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + event.preventDefault(); + const delta = edge === "left" ? state.startX - event.clientX : event.clientX - state.startX; + state.pending = clamp(state.startWidth + delta); + if (state.rafId !== null) return; + state.rafId = requestAnimationFrame(() => { + const active = dragStateRef.current; + if (!active) return; + active.rafId = null; + setWidth(active.pending); + }); + }, + [clamp, edge], + ); + + const onPointerUp = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + const finalWidth = clamp(state.pending); + releasePointer(event.pointerId); + // Commit once at drag-end to avoid 60Hz localStorage writes. + try { + setLocalStorageItem(storageKey, finalWidth, WidthSchema); + } catch { + // localStorage may be full / disabled; the in-memory state still wins. + } + setWidth(finalWidth); + }, + [clamp, releasePointer, storageKey], + ); + + const onPointerCancel = useCallback( + (event: ReactPointerEvent) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + // Don't persist a cancelled drag; revert to the start width. + releasePointer(event.pointerId); + setWidth(state.startWidth); + }, + [releasePointer], + ); + + return { + width, + handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, + }; +} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 005c8ad82fc..7fc294cf067 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -10,6 +10,7 @@ * store. */ import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { ServerSettings, type ServerSettingsPatch } from "@t3tools/contracts"; import { type ClientSettingsPatch, @@ -20,8 +21,8 @@ import { } from "@t3tools/contracts/settings"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; -import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { applySettingsUpdated, getServerConfig, useServerSettings } from "~/rpc/serverState"; +import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; +import { usePrimaryEnvironment } from "~/state/environments"; const CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE = "[CLIENT_SETTINGS]"; @@ -168,7 +169,7 @@ export function useClientSettingsHydrated(): boolean { } export function useSettings(selector?: (s: UnifiedSettings) => T): T { - const serverSettings = useServerSettings(); + const serverSettings = useAtomValue(primaryServerSettingsAtom); const clientSettings = useSyncExternalStore( subscribeClientSettings, getClientSettingsSnapshot, @@ -193,25 +194,32 @@ export function useSettings(selector?: (s: UnifiedSettings) * persisted via RPC. Client keys go through client persistence. */ export function useUpdateSettings() { - const updateSettings = useCallback((patch: Partial) => { - const { serverPatch, clientPatch } = splitPatch(patch); - - if (Object.keys(serverPatch).length > 0) { - const currentServerConfig = getServerConfig(); - if (currentServerConfig) { - applySettingsUpdated(applyServerSettingsPatch(currentServerConfig.settings, serverPatch)); + const persistServerSettings = useAtomSet(serverEnvironment.updateSettings, { + mode: "promise", + }); + const primaryEnvironment = usePrimaryEnvironment(); + const updateSettings = useCallback( + (patch: Partial) => { + const { serverPatch, clientPatch } = splitPatch(patch); + + if (Object.keys(serverPatch).length > 0) { + if (primaryEnvironment) { + void persistServerSettings({ + environmentId: primaryEnvironment.environmentId, + input: { patch: serverPatch }, + }); + } } - // Fire-and-forget RPC — push will reconcile on success - void ensureLocalApi().server.updateSettings(serverPatch); - } - if (Object.keys(clientPatch).length > 0) { - persistClientSettings({ - ...getClientSettingsSnapshot(), - ...clientPatch, - }); - } - }, []); + if (Object.keys(clientPatch).length > 0) { + persistClientSettings({ + ...getClientSettingsSnapshot(), + ...clientPatch, + }); + } + }, + [persistServerSettings, primaryEnvironment], + ); const resetSettings = useCallback(() => { updateSettings(DEFAULT_UNIFIED_SETTINGS); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 7325a96913d..440474b8edb 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,22 +1,22 @@ -import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; +import { + parseScopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; import { useRouter } from "@tanstack/react-router"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; +import { terminalEnvironment } from "../state/terminal"; +import { threadEnvironment } from "../state/threads"; +import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; -import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi"; -import { invalidateSourceControlState } from "../lib/sourceControlActions"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; -import { newCommandId } from "../lib/utils"; import { readLocalApi } from "../localApi"; -import { - selectProjectByRef, - selectThreadByRef, - selectThreadsForEnvironment, - useStore, -} from "../store"; +import { readEnvironmentThreadRefs, readProject, readThreadShell } from "../state/entities"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteRef } from "../threadRoutes"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; @@ -24,6 +24,13 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; export function useThreadActions() { + const closeTerminal = useAtomSet(terminalEnvironment.close, { mode: "promise" }); + const archiveThreadMutation = useAtomSet(threadEnvironment.archive, { mode: "promise" }); + const unarchiveThreadMutation = useAtomSet(threadEnvironment.unarchive, { mode: "promise" }); + const deleteThreadMutation = useAtomSet(threadEnvironment.delete, { mode: "promise" }); + const stopThreadSession = useAtomSet(threadEnvironment.stopSession, { mode: "promise" }); + const removeWorktree = useAtomSet(vcsEnvironment.removeWorktree, { mode: "promise" }); + const refreshVcsStatus = useAtomSet(vcsEnvironment.refreshStatus, { mode: "promise" }); const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread); @@ -41,8 +48,7 @@ export function useThreadActions() { handleNewThreadRef.current = handleNewThread; const resolveThreadTarget = useCallback((target: ScopedThreadRef) => { - const state = useStore.getState(); - const thread = selectThreadByRef(state, target); + const thread = readThreadShell(target); if (!thread) { return null; } @@ -58,8 +64,6 @@ export function useThreadActions() { const archiveThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); if (!resolved) return; const { thread, threadRef } = resolved; @@ -71,10 +75,9 @@ export function useThreadActions() { const shouldNavigateToDraft = currentRouteThreadRef?.threadId === threadRef.threadId && currentRouteThreadRef.environmentId === threadRef.environmentId; - const archiveCommand = api.orchestration.dispatchCommand({ - type: "thread.archive", - commandId: newCommandId(), - threadId: threadRef.threadId, + const archiveCommand = archiveThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); if (shouldNavigateToDraft) { @@ -84,39 +87,38 @@ export function useThreadActions() { await archiveCommand; refreshArchivedThreadsForEnvironment(threadRef.environmentId); }, - [getCurrentRouteThreadRef, resolveThreadTarget], + [archiveThreadMutation, getCurrentRouteThreadRef, resolveThreadTarget], ); - const unarchiveThread = useCallback(async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; - await api.orchestration.dispatchCommand({ - type: "thread.unarchive", - commandId: newCommandId(), - threadId: target.threadId, - }); - refreshArchivedThreadsForEnvironment(target.environmentId); - }, []); + const unarchiveThread = useCallback( + async (target: ScopedThreadRef) => { + await unarchiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, + }); + refreshArchivedThreadsForEnvironment(target.environmentId); + }, + [unarchiveThreadMutation], + ); const deleteThread = useCallback( async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const resolved = resolveThreadTarget(target); if (!resolved) { // Thread not in main store (e.g. archived thread) — dispatch delete directly. - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: target.threadId, + await deleteThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId }, }); refreshArchivedThreadsForEnvironment(target.environmentId); return; } const { thread, threadRef } = resolved; - const state = useStore.getState(); - const threads = selectThreadsForEnvironment(state, threadRef.environmentId); - const threadProject = selectProjectByRef(state, { + const threads = readEnvironmentThreadRefs(threadRef.environmentId).flatMap((ref) => { + const shell = readThreadShell(ref); + return shell === null ? [] : [shell]; + }); + const threadProject = readProject({ environmentId: threadRef.environmentId, projectId: thread.projectId, }); @@ -140,7 +142,7 @@ export function useThreadActions() { const displayWorktreePath = orphanedWorktreePath ? formatWorktreePathForDisplay(orphanedWorktreePath) : null; - const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== undefined; + const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null; const localApi = readLocalApi(); const shouldDeleteWorktree = canDeleteWorktree && @@ -154,19 +156,18 @@ export function useThreadActions() { ].join("\n"), )); - if (thread.session && thread.session.status !== "closed") { - await api.orchestration - .dispatchCommand({ - type: "thread.session.stop", - commandId: newCommandId(), - threadId: threadRef.threadId, - createdAt: new Date().toISOString(), - }) - .catch(() => undefined); + if (thread.session && thread.session.status !== "stopped") { + await stopThreadSession({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }).catch(() => undefined); } try { - await api.terminal.close({ threadId: threadRef.threadId, deleteHistory: true }); + await closeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, deleteHistory: true }, + }); } catch { // Terminal may already be closed. } @@ -182,10 +183,9 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, }); - await api.orchestration.dispatchCommand({ - type: "thread.delete", - commandId: newCommandId(), - threadId: threadRef.threadId, + await deleteThreadMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, }); refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); @@ -197,8 +197,7 @@ export function useThreadActions() { if (shouldNavigateToFallback) { if (fallbackThreadId) { - const fallbackThread = selectThreadByRef( - useStore.getState(), + const fallbackThread = readThreadShell( scopeThreadRef(threadRef.environmentId, fallbackThreadId), ); if (fallbackThread) { @@ -222,19 +221,23 @@ export function useThreadActions() { } try { - await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ - cwd: threadProject.cwd, - path: orphanedWorktreePath, - force: true, + await removeWorktree({ + environmentId: threadRef.environmentId, + input: { + cwd: threadProject.workspaceRoot, + path: orphanedWorktreePath, + force: true, + }, }); - await invalidateSourceControlState({ + await refreshVcsStatus({ environmentId: threadRef.environmentId, + input: { cwd: threadProject.workspaceRoot }, }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error removing worktree."; console.error("Failed to remove orphaned worktree after thread deletion", { threadId: threadRef.threadId, - projectCwd: threadProject.cwd, + projectCwd: threadProject.workspaceRoot, worktreePath: orphanedWorktreePath, error, }); @@ -251,17 +254,20 @@ export function useThreadActions() { clearComposerDraftForThread, clearProjectDraftThreadById, clearTerminalUiState, + closeTerminal, + deleteThreadMutation, getCurrentRouteThreadRef, + refreshVcsStatus, + removeWorktree, router, resolveThreadTarget, sidebarThreadSortOrder, + stopThreadSession, ], ); const confirmAndDeleteThread = useCallback( async (target: ScopedThreadRef) => { - const api = readEnvironmentApi(target.environmentId); - if (!api) return; const localApi = readLocalApi(); const resolved = resolveThreadTarget(target); @@ -283,10 +289,13 @@ export function useThreadActions() { [confirmThreadDelete, deleteThread, resolveThreadTarget], ); - return { - archiveThread, - unarchiveThread, - deleteThread, - confirmAndDeleteThread, - }; + return useMemo( + () => ({ + archiveThread, + unarchiveThread, + deleteThread, + confirmAndDeleteThread, + }), + [archiveThread, confirmAndDeleteThread, deleteThread, unarchiveThread], + ); } diff --git a/apps/web/src/hooks/useTurnDiffSummaries.ts b/apps/web/src/hooks/useTurnDiffSummaries.ts index 2bf72c96cca..f51acc15cc0 100644 --- a/apps/web/src/hooks/useTurnDiffSummaries.ts +++ b/apps/web/src/hooks/useTurnDiffSummaries.ts @@ -1,13 +1,13 @@ import { useMemo } from "react"; import { inferCheckpointTurnCountByTurnId } from "../session-logic"; -import type { Thread } from "../types"; +import type { Thread, TurnDiffSummary } from "../types"; -export function useTurnDiffSummaries(activeThread: Thread | undefined) { - const turnDiffSummaries = useMemo(() => { +export function useTurnDiffSummaries(activeThread: Thread | null | undefined) { + const turnDiffSummaries = useMemo>(() => { if (!activeThread) { return []; } - return activeThread.turnDiffSummaries; + return activeThread.checkpoints; }, [activeThread]); const inferredCheckpointTurnCountByTurnId = useMemo( diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index a0190ed9b5c..5563378da97 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -18,6 +18,7 @@ import { isTerminalCloseShortcut, isTerminalNewShortcut, isTerminalSplitShortcut, + isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, shouldShowModelPickerJumpHints, @@ -85,6 +86,7 @@ function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig { const DEFAULT_BINDINGS = compile([ { shortcut: modShortcut("j"), command: "terminal.toggle" }, + { shortcut: modShortcut("b", { altKey: true }), command: "rightPanel.toggle" }, { shortcut: modShortcut("d"), command: "terminal.split", @@ -92,6 +94,11 @@ const DEFAULT_BINDINGS = compile([ }, { shortcut: modShortcut("d", { shiftKey: true }), + command: "terminal.splitVertical", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("terminalFocus"), }, @@ -174,7 +181,17 @@ describe("split/new/close terminal shortcuts", () => { }), ); assert.isFalse( - isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isTerminalSplitVerticalShortcut( + event({ key: "d", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: false }, + }, + ), + ); + assert.isFalse( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", context: { terminalFocus: false }, }), @@ -195,7 +212,17 @@ describe("split/new/close terminal shortcuts", () => { }), ); assert.isTrue( - isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + isTerminalSplitVerticalShortcut( + event({ key: "d", metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + context: { terminalFocus: true }, + }, + ), + ); + assert.isTrue( + isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", context: { terminalFocus: true }, }), @@ -287,6 +314,10 @@ describe("shortcutLabelForCommand", () => { it("returns effective labels for non-terminal commands", () => { assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "chat.new", "MacIntel"), "⇧⌘O"); assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "diff.toggle", "Linux"), "Ctrl+D"); + assert.strictEqual( + shortcutLabelForCommand(DEFAULT_BINDINGS, "rightPanel.toggle", "MacIntel"), + "⌥⌘B", + ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index dbf2450f794..24ed02223d0 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -30,6 +30,8 @@ export interface ShortcutModifierStateLike { export interface ShortcutMatchContext { terminalFocus: boolean; terminalOpen: boolean; + previewFocus: boolean; + previewOpen: boolean; [key: string]: boolean; } @@ -112,6 +114,8 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc return { terminalFocus: false, terminalOpen: false, + previewFocus: false, + previewOpen: false, ...options?.context, }; } @@ -355,6 +359,14 @@ export function isTerminalSplitShortcut( return matchesCommandShortcut(event, keybindings, "terminal.split", options); } +export function isTerminalSplitVerticalShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "terminal.splitVertical", options); +} + export function isTerminalNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, @@ -379,6 +391,30 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } +export function isPreviewToggleShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.toggle", options); +} + +export function isPreviewRefreshShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.refresh", options); +} + +export function isPreviewFocusUrlShortcut( + event: ShortcutEventLike, + keybindings: ResolvedKeybindingsConfig, + options?: ShortcutMatchOptions, +): boolean { + return matchesCommandShortcut(event, keybindings, "preview.focusUrl", options); +} + export function isChatNewShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index f465b620a28..b39123e0eac 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -1,23 +1,62 @@ import { useAtomValue } from "@effect/atom-react"; import { type ArchivedSnapshotEntry, - createArchivedThreadsManager, makeArchivedThreadsEnvironmentKey, - readArchivedThreadsSnapshotState, -} from "@t3tools/client-runtime"; + parseArchivedThreadsEnvironmentKey, +} from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; -import { readEnvironmentApi } from "../environmentApi"; +import { orchestrationEnvironment } from "../state/orchestration"; import { appAtomRegistry } from "../rpc/atomRegistry"; -const archivedThreadsManager = createArchivedThreadsManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, -}); +const archivedSnapshotsAtom = Atom.family((environmentKey: string) => + Atom.make((get) => { + const snapshots: ArchivedSnapshotEntry[] = []; + let error: string | null = null; + let isLoading = false; + + for (const environmentId of parseArchivedThreadsEnvironmentKey(environmentKey)) { + const result = get( + orchestrationEnvironment.archivedShellSnapshot({ + environmentId, + input: {}, + }), + ); + isLoading ||= result.waiting; + const snapshot = Option.getOrNull(AsyncResult.value(result)); + if (snapshot !== null) { + snapshots.push({ environmentId, snapshot }); + } + if (error === null && result._tag === "Failure") { + const cause = Cause.squash(result.cause); + error = + cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load archived threads."; + } + } + + return { + snapshots, + error, + isLoading, + }; + }).pipe(Atom.withLabel(`web:archived-thread-snapshots:${environmentKey}`)), +); + +function archivedSnapshotAtom(environmentId: EnvironmentId) { + return orchestrationEnvironment.archivedShellSnapshot({ + environmentId, + input: {}, + }); +} export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentId): void { - archivedThreadsManager.refreshForEnvironment(environmentId); + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); } export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { @@ -30,14 +69,15 @@ export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray makeArchivedThreadsEnvironmentKey(environmentIds), [environmentIds], ); - const atom = archivedThreadsManager.getAtom(environmentKey); - const result = useAtomValue(atom); + const result = useAtomValue(archivedSnapshotsAtom(environmentKey)); const refresh = useCallback(() => { - archivedThreadsManager.refresh(environmentIds); + for (const environmentId of environmentIds) { + appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); + } }, [environmentIds]); return { - ...readArchivedThreadsSnapshotState(result), + ...result, refresh, }; } diff --git a/apps/web/src/lib/chatThreadActions.test.ts b/apps/web/src/lib/chatThreadActions.test.ts index 56c7508f9e5..45d22b1df91 100644 --- a/apps/web/src/lib/chatThreadActions.test.ts +++ b/apps/web/src/lib/chatThreadActions.test.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { diff --git a/apps/web/src/lib/chatThreadActions.ts b/apps/web/src/lib/chatThreadActions.ts index 39826e8af3d..b434d1f519f 100644 --- a/apps/web/src/lib/chatThreadActions.ts +++ b/apps/web/src/lib/chatThreadActions.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectId, ScopedProjectRef } from "@t3tools/contracts"; import type { DraftThreadEnvMode } from "../composerDraftStore"; diff --git a/apps/web/src/lib/checkpointDiffState.ts b/apps/web/src/lib/checkpointDiffState.ts index afd38b84e5d..067e22d51df 100644 --- a/apps/web/src/lib/checkpointDiffState.ts +++ b/apps/web/src/lib/checkpointDiffState.ts @@ -1,63 +1,18 @@ -import { useAtomValue } from "@effect/atom-react"; import { type CheckpointDiffState, type CheckpointDiffTarget, - checkpointDiffStateAtom, - createCheckpointDiffManager, - EMPTY_CHECKPOINT_DIFF_ATOM, - EMPTY_CHECKPOINT_DIFF_STATE, - getCheckpointDiffTargetKey, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +} from "@t3tools/client-runtime/state/threads"; -import { readEnvironmentApi } from "../environmentApi"; -import { subscribeProviderInvalidations } from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const checkpointDiffManager = createCheckpointDiffManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentApi(environmentId)?.orchestration ?? null, -}); - -export function invalidateCheckpointDiffs(): void { - checkpointDiffManager.invalidate(); -} - -subscribeProviderInvalidations(invalidateCheckpointDiffs); +import { useCheckpointDiff as useCheckpointDiffQuery } from "../state/queries"; export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, ): CheckpointDiffState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - threadId: target.threadId, - fromTurnCount: target.fromTurnCount, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - cacheScope: target.cacheScope ?? null, - }), - [ - target.cacheScope, - target.environmentId, - target.fromTurnCount, - target.ignoreWhitespace, - target.threadId, - target.toTurnCount, - ], - ); - const targetKey = getCheckpointDiffTargetKey(stableTarget); - - useEffect(() => { - if (targetKey === null || options?.enabled === false) { - return; - } - void checkpointDiffManager.load(stableTarget); - }, [options?.enabled, stableTarget, targetKey]); - - const state = useAtomValue( - targetKey !== null ? checkpointDiffStateAtom(targetKey) : EMPTY_CHECKPOINT_DIFF_ATOM, - ); - return targetKey === null || options?.enabled === false ? EMPTY_CHECKPOINT_DIFF_STATE : state; + const state = useCheckpointDiffQuery(target, options); + return { + data: state.data, + error: state.error, + isPending: state.isPending, + }; } diff --git a/apps/web/src/lib/composerPathSearchState.ts b/apps/web/src/lib/composerPathSearchState.ts index e25f60ad13d..0f35f6dbffe 100644 --- a/apps/web/src/lib/composerPathSearchState.ts +++ b/apps/web/src/lib/composerPathSearchState.ts @@ -1,57 +1,19 @@ -import { useAtomValue } from "@effect/atom-react"; import { type ComposerPathSearchState, type ComposerPathSearchTarget, - EMPTY_COMPOSER_PATH_SEARCH_ATOM, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - composerPathSearchStateAtom, - createComposerPathSearchManager, - getComposerPathSearchTargetKey, - normalizeComposerPathSearchQuery, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; +} from "@t3tools/client-runtime/state/threads"; -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, - subscribeProviderInvalidations, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const COMPOSER_PATH_SEARCH_LIMIT = 80; -const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; -const COMPOSER_PATH_SEARCH_STALE_TIME_MS = 15_000; - -const composerPathSearchManager = createComposerPathSearchManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.projects ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - limit: COMPOSER_PATH_SEARCH_LIMIT, - debounceMs: COMPOSER_PATH_SEARCH_DEBOUNCE_MS, - staleTimeMs: COMPOSER_PATH_SEARCH_STALE_TIME_MS, -}); - -export function invalidateComposerPathSearches(): void { - composerPathSearchManager.invalidate(); -} - -subscribeProviderInvalidations(invalidateComposerPathSearches); +import { useComposerPathSearch as useComposerPathSearchQuery } from "../state/queries"; export function useComposerPathSearch(target: ComposerPathSearchTarget): ComposerPathSearchState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getComposerPathSearchTargetKey(stableTarget); - - useEffect(() => composerPathSearchManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue( - targetKey !== null ? composerPathSearchStateAtom(targetKey) : EMPTY_COMPOSER_PATH_SEARCH_ATOM, - ); - return targetKey === null ? EMPTY_COMPOSER_PATH_SEARCH_STATE : state; + const state = useComposerPathSearchQuery(target); + return { + entries: state.entries.map((entry) => ({ + path: entry.path, + kind: entry.kind, + ...(entry.parentPath === undefined ? {} : { parentPath: entry.parentPath }), + })), + error: state.error, + isPending: state.isPending, + }; } diff --git a/apps/web/src/lib/elementContext.test.ts b/apps/web/src/lib/elementContext.test.ts new file mode 100644 index 00000000000..a8c740741eb --- /dev/null +++ b/apps/web/src/lib/elementContext.test.ts @@ -0,0 +1,294 @@ +import type { PickedElementPayload } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + appendElementContextsToPrompt, + buildElementContextBlock, + type ElementContextSelection, + elementContextDedupKey, + extractTrailingElementContexts, + formatElementContextLabel, + formatElementContextSourceLabel, + newElementContextId, + normalizeElementContextSelection, +} from "./elementContext"; + +function makePayload(overrides?: Partial): PickedElementPayload { + return { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + 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, + }; +} + +function makeSelection(overrides?: Partial): ElementContextSelection { + return { + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + tagName: "button", + selector: "button.submit", + htmlPreview: '', + componentName: "SubmitButton", + source: { + functionName: "SubmitButton", + fileName: "/repo/src/Button.tsx", + lineNumber: 12, + columnNumber: 5, + }, + styles: ".submit { color: white; }", + ...overrides, + }; +} + +describe("normalizeElementContextSelection", () => { + it("lowercases the tag, trims strings, and prefers `source` over `stack[0]`", () => { + const result = normalizeElementContextSelection( + makePayload({ + tagName: " Button ", + pageUrl: " https://example.com ", + pageTitle: " Dashboard ", + selector: " ", + componentName: " ", + source: { + functionName: " Outer ", + fileName: " /repo/Outer.tsx ", + lineNumber: 7, + columnNumber: 0, + }, + stack: [ + { + functionName: "Inner", + fileName: "/repo/Inner.tsx", + lineNumber: 99, + columnNumber: 9, + }, + ], + }), + ); + expect(result).not.toBeNull(); + expect(result?.tagName).toBe("button"); + expect(result?.pageUrl).toBe("https://example.com"); + expect(result?.pageTitle).toBe("Dashboard"); + expect(result?.selector).toBeNull(); + expect(result?.componentName).toBeNull(); + expect(result?.source).toEqual({ + functionName: "Outer", + fileName: "/repo/Outer.tsx", + lineNumber: 7, + columnNumber: 0, + }); + }); + + it("returns null when pageUrl or tagName is empty", () => { + expect(normalizeElementContextSelection(makePayload({ pageUrl: "" }))).toBeNull(); + expect(normalizeElementContextSelection(makePayload({ tagName: " " }))).toBeNull(); + }); + + it("clamps oversized htmlPreview / styles so we don't blow localStorage", () => { + const huge = "x".repeat(10_000); + const result = normalizeElementContextSelection( + makePayload({ htmlPreview: huge, styles: huge }), + ); + expect(result).not.toBeNull(); + expect(result!.htmlPreview.length).toBeLessThanOrEqual(4000); + expect(result!.styles.length).toBeLessThanOrEqual(4000); + // Truncated values should end with the ellipsis sentinel + expect(result!.htmlPreview.endsWith("…")).toBe(true); + expect(result!.styles.endsWith("…")).toBe(true); + }); + + it("normalizes Windows line endings inside html/styles", () => { + const result = normalizeElementContextSelection( + makePayload({ htmlPreview: "\r\nhi\r\n", styles: ".a {\r\n color: red;\r\n}" }), + ); + expect(result?.htmlPreview).toBe("
\nhi\n"); + expect(result?.styles).toBe(".a {\n color: red;\n}"); + }); + + it("falls back to stack[0] when payload.source is null", () => { + const result = normalizeElementContextSelection( + makePayload({ + source: null, + stack: [ + { + functionName: "FromStack", + fileName: "/repo/FromStack.tsx", + lineNumber: 3, + columnNumber: null, + }, + ], + }), + ); + expect(result?.source).toEqual({ + functionName: "FromStack", + fileName: "/repo/FromStack.tsx", + lineNumber: 3, + columnNumber: null, + }); + }); +}); + +describe("formatElementContextLabel", () => { + it("prefers component name over tag name", () => { + expect(formatElementContextLabel(makeSelection())).toBe(""); + }); + + it("falls back to tag name when no component name is present", () => { + expect(formatElementContextLabel(makeSelection({ componentName: null }))).toBe("', + " styles:", + " .submit { color: white; }", + "", + ].join("\n"), + ); + }); + + it("emits no leading blank when prompt is empty", () => { + expect( + appendElementContextsToPrompt("", [makeSelection()]).startsWith(""), + ).toBe(true); + }); +}); + +describe("extractTrailingElementContexts", () => { + it("round-trips appendElementContextsToPrompt and recovers prompt + entries", () => { + const prompt = appendElementContextsToPrompt("Investigate this", [ + makeSelection(), + makeSelection({ selector: "button.cancel", componentName: "CancelButton" }), + ]); + const result = extractTrailingElementContexts(prompt); + expect(result.promptText).toBe("Investigate this"); + expect(result.contextCount).toBe(2); + expect(result.contexts.map((c) => c.header)).toEqual([ + " (Button.tsx:12)", + " (Button.tsx:12)", + ]); + expect(result.contexts[0]?.body).toContain("url: https://example.com/dashboard"); + expect(result.contexts[0]?.body).toContain("selector: button.submit"); + }); + + it("returns the original prompt unchanged when no trailing block exists", () => { + expect(extractTrailingElementContexts("hi")).toEqual({ + promptText: "hi", + contextCount: 0, + contexts: [], + }); + }); +}); + +describe("newElementContextId", () => { + it("returns a non-empty string with the element prefix", () => { + const id = newElementContextId(); + expect(id.startsWith("el_")).toBe(true); + expect(id.length).toBeGreaterThan(3); + }); + + it("returns unique ids on repeated calls", () => { + const ids = new Set(Array.from({ length: 10 }, () => newElementContextId())); + expect(ids.size).toBe(10); + }); +}); diff --git a/apps/web/src/lib/elementContext.ts b/apps/web/src/lib/elementContext.ts new file mode 100644 index 00000000000..8064db71079 --- /dev/null +++ b/apps/web/src/lib/elementContext.ts @@ -0,0 +1,244 @@ +import { type ThreadId } from "@t3tools/contracts"; +import type { PickedElementPayload, PickedElementStackFrame } from "@t3tools/contracts"; + +const ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT = 4000; +const ELEMENT_CONTEXT_STYLES_LIMIT = 4000; +const ELEMENT_CONTEXT_LABEL_TAG_MAX = 24; + +const TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN = + /\n*\n([\s\S]*?)\n<\/element_context>\s*$/; + +/** + * Stable, persistable element selection captured from the in-app preview + * browser. We deliberately keep the shape JSON-serializable so it can ride + * through `localStorage` persistence, draft restoration, and transcript + * snapshots without bespoke marshalling. + */ +export interface ElementContextSelection { + /** Page URL where the element was picked. */ + pageUrl: string; + /** Best-effort ``. */ + pageTitle: string | null; + /** Lowercase tag, e.g. `"button"`. */ + tagName: string; + /** CSS selector — may be null when react-grab can't compute one. */ + selector: string | null; + /** Truncated outer-HTML preview. */ + htmlPreview: string; + /** Nearest React component display name, or null. */ + componentName: string | null; + /** Source frame (file + line) — null when unavailable. */ + source: PickedElementStackFrame | null; + /** Author CSS (no UA defaults). May be empty. */ + styles: string; +} + +export interface ElementContextDraft extends ElementContextSelection { + /** Stable composer-side id used for keyed rendering + dedupe. */ + id: string; + threadId: ThreadId; + /** ISO-8601 wall clock pick time. */ + pickedAt: string; +} + +export interface ParsedElementContextEntry { + header: string; + body: string; +} + +export interface ExtractedElementContexts { + promptText: string; + contextCount: number; + contexts: ParsedElementContextEntry[]; +} + +function truncateString(value: string, limit: number): string { + if (value.length <= limit) return value; + return `${value.slice(0, Math.max(0, limit - 1))}…`; +} + +function normalizeText(value: string): string { + return value.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); +} + +/** + * Sanitize a payload coming back from the desktop bridge before it lands in + * the composer draft. Trims/clamps every string field so we never persist a + * 5MB outerHTML blob and silently break `localStorage`. + */ +export function normalizeElementContextSelection( + raw: PickedElementPayload, +): ElementContextSelection | null { + const pageUrl = raw.pageUrl.trim(); + const tagName = raw.tagName.trim().toLowerCase(); + if (pageUrl.length === 0 || tagName.length === 0) { + return null; + } + const stackFrame = raw.source ?? raw.stack[0] ?? null; + return { + pageUrl, + pageTitle: raw.pageTitle?.trim() ?? null, + tagName, + selector: raw.selector?.trim() || null, + htmlPreview: truncateString(normalizeText(raw.htmlPreview), ELEMENT_CONTEXT_HTML_PREVIEW_LIMIT), + componentName: raw.componentName?.trim() || null, + source: stackFrame + ? { + functionName: stackFrame.functionName?.trim() || null, + fileName: stackFrame.fileName?.trim() || null, + lineNumber: stackFrame.lineNumber ?? null, + columnNumber: stackFrame.columnNumber ?? null, + } + : null, + styles: truncateString(normalizeText(raw.styles), ELEMENT_CONTEXT_STYLES_LIMIT), + }; +} + +/** + * Stable dedupe key. Two picks of the same element on the same page produce + * the same key, so we don't end up with a runaway chip row from spam-clicks. + */ +export function elementContextDedupKey(context: ElementContextSelection): string { + return [context.pageUrl, context.selector ?? "", context.tagName, context.componentName ?? ""] + .join("|") + .toLowerCase(); +} + +function shortenTagLabel(tagName: string): string { + if (tagName.length <= ELEMENT_CONTEXT_LABEL_TAG_MAX) return tagName; + return `${tagName.slice(0, ELEMENT_CONTEXT_LABEL_TAG_MAX - 1)}…`; +} + +/** + * Compact chip label — `<Button>` for component picks, `<button>` otherwise. + * Component name takes priority because it's higher-signal for the agent. + */ +export function formatElementContextLabel(context: ElementContextSelection): string { + if (context.componentName) return `<${context.componentName}>`; + return `<${shortenTagLabel(context.tagName)}>`; +} + +function basenameFromPath(filePath: string): string { + const parts = filePath.split(/[\\/]/); + return parts[parts.length - 1] ?? filePath; +} + +export function formatElementContextSourceLabel(context: ElementContextSelection): string | null { + const source = context.source; + if (!source?.fileName) return null; + const base = basenameFromPath(source.fileName); + if (source.lineNumber == null) return base; + return `${base}:${source.lineNumber}`; +} + +function buildContextHeader(context: ElementContextSelection): string { + const label = formatElementContextLabel(context); + const source = formatElementContextSourceLabel(context); + return source ? `${label} (${source})` : label; +} + +function indentLines(value: string): string[] { + return value.split("\n").map((line) => ` ${line}`); +} + +function buildSingleContextLines(context: ElementContextSelection): string[] { + const lines: string[] = []; + lines.push(`- ${buildContextHeader(context)}:`); + if (context.pageUrl.length > 0) { + lines.push(` url: ${context.pageUrl}`); + } + if (context.selector) { + lines.push(` selector: ${context.selector}`); + } + if (context.source?.fileName) { + const { fileName, lineNumber, columnNumber } = context.source; + const location = + lineNumber != null + ? `${fileName}:${lineNumber}${columnNumber != null ? `:${columnNumber}` : ""}` + : fileName; + lines.push(` source: ${location}`); + } + const html = context.htmlPreview.trim(); + if (html.length > 0) { + lines.push(" html:"); + lines.push(...indentLines(html)); + } + const styles = context.styles.trim(); + if (styles.length > 0) { + lines.push(" styles:"); + lines.push(...indentLines(styles)); + } + return lines; +} + +/** + * Serialize element-context drafts into the `<element_context>` block we + * append to the user's outgoing message text. Mirrors the `<terminal_context>` + * block format so it composes cleanly when both are present. + */ +export function buildElementContextBlock(contexts: ReadonlyArray<ElementContextSelection>): string { + if (contexts.length === 0) return ""; + const lines: string[] = []; + for (let index = 0; index < contexts.length; index += 1) { + const context = contexts[index]!; + lines.push(...buildSingleContextLines(context)); + if (index < contexts.length - 1) lines.push(""); + } + return ["<element_context>", ...lines, "</element_context>"].join("\n"); +} + +export function appendElementContextsToPrompt( + prompt: string, + contexts: ReadonlyArray<ElementContextSelection>, +): string { + const block = buildElementContextBlock(contexts); + if (block.length === 0) return prompt; + const trimmed = prompt.trim(); + return trimmed.length > 0 ? `${trimmed}\n\n${block}` : block; +} + +const ELEMENT_CONTEXT_ID_PREFIX = "el_"; +let nextElementContextSequence = 0; + +export function newElementContextId(): string { + nextElementContextSequence += 1; + return `${ELEMENT_CONTEXT_ID_PREFIX}${nextElementContextSequence.toString(36)}`; +} + +/** + * Mirror image of `appendElementContextsToPrompt` for transcript display: + * detects (and strips) a trailing `<element_context>` block so we can render + * the original prompt body and chips separately in user-message bubbles. + */ +export function extractTrailingElementContexts(prompt: string): ExtractedElementContexts { + const match = TRAILING_ELEMENT_CONTEXT_BLOCK_PATTERN.exec(prompt); + if (!match) { + return { promptText: prompt, contextCount: 0, contexts: [] }; + } + const promptText = prompt.slice(0, match.index).replace(/\n+$/, ""); + const contexts = parseElementContextEntries(match[1] ?? ""); + return { promptText, contextCount: contexts.length, contexts }; +} + +function parseElementContextEntries(block: string): ParsedElementContextEntry[] { + const entries: ParsedElementContextEntry[] = []; + let current: { header: string; bodyLines: string[] } | null = null; + const commit = () => { + if (!current) return; + entries.push({ header: current.header, body: current.bodyLines.join("\n").trimEnd() }); + current = null; + }; + for (const line of block.split("\n")) { + const headerMatch = /^- (.+):$/.exec(line); + if (headerMatch) { + commit(); + current = { header: headerMatch[1]!, bodyLines: [] }; + continue; + } + if (!current) continue; + if (line.startsWith(" ")) current.bodyLines.push(line.slice(2)); + else if (line.length === 0) current.bodyLines.push(""); + } + commit(); + return entries; +} diff --git a/apps/web/src/lib/favicon.ts b/apps/web/src/lib/favicon.ts new file mode 100644 index 00000000000..e5e94b2666f --- /dev/null +++ b/apps/web/src/lib/favicon.ts @@ -0,0 +1,20 @@ +/** + * Favicon helpers for the preview tab strip. + * + * Uses Google's s2 favicon endpoint (same approach as ami's tab strip). + * Callers should always render a `<Globe />` fallback when the returned URL + * fails to load via an `onError` handler. + */ +const FAVICON_PROVIDER = "https://www.google.com/s2/favicons"; + +export function faviconUrlForOrigin(rawUrl: string | null | undefined, size = 32): string | null { + if (!rawUrl) return null; + try { + const url = new URL(rawUrl); + if (!url.host) return null; + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return `${FAVICON_PROVIDER}?domain=${encodeURIComponent(url.host)}&sz=${size}`; + } catch { + return null; + } +} diff --git a/apps/web/src/lib/previewAnnotation.test.ts b/apps/web/src/lib/previewAnnotation.test.ts new file mode 100644 index 00000000000..05f4b8d6273 --- /dev/null +++ b/apps/web/src/lib/previewAnnotation.test.ts @@ -0,0 +1,87 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + appendPreviewAnnotationPrompt, + buildPreviewAnnotationPrompt, + extractTrailingPreviewAnnotation, +} from "./previewAnnotation"; + +const annotation: PreviewAnnotationPayload = { + id: "annotation_1", + pageUrl: "http://localhost:3000", + pageTitle: "Example", + comment: "Make these cards feel related.", + elements: [], + regions: [{ id: "region_1", rect: { x: 10, y: 20, width: 100, height: 80 } }], + 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: ".card", + property: "border-radius", + previousValue: "4px", + value: "16px", + }, + ], + screenshot: { + dataUrl: "data:image/png;base64,AA==", + width: 100, + height: 80, + cropRect: { x: 10, y: 20, width: 100, height: 80 }, + }, + createdAt: "2026-06-11T00:00:00.000Z", +}; + +describe("preview annotations", () => { + it("describes regions, drawings, styles, and screenshot context", () => { + const result = buildPreviewAnnotationPrompt(annotation); + expect(result).toContain("Make these cards feel related."); + expect(result).toContain("1 marked region"); + expect(result).toContain("1 drawing"); + expect(result).toContain("border-radius: 4px → 16px"); + expect(result).toContain("attached screenshot"); + }); + + it("appends to an existing composer prompt", () => { + expect( + appendPreviewAnnotationPrompt("Fix this", annotation).startsWith( + "Fix this\n\n<preview_annotation>", + ), + ).toBe(true); + }); + + it("extracts annotation presentation from a sent prompt", () => { + const result = extractTrailingPreviewAnnotation( + appendPreviewAnnotationPrompt("Fix this", annotation), + ); + expect(result.promptText).toBe("Fix this"); + expect(result.annotation).toMatchObject({ + title: "Example", + targetSummary: "1 marked region, 1 drawing.", + hasScreenshot: true, + }); + }); + + it("extracts multiple trailing annotations one at a time", () => { + const first = appendPreviewAnnotationPrompt("Fix this", annotation); + const secondAnnotation = { ...annotation, id: "annotation_2", pageTitle: "Details" }; + const second = appendPreviewAnnotationPrompt(first, secondAnnotation); + const extractedSecond = extractTrailingPreviewAnnotation(second); + const extractedFirst = extractTrailingPreviewAnnotation(extractedSecond.promptText); + expect(extractedSecond.annotation?.id).toBe("annotation_2"); + expect(extractedFirst.annotation?.id).toBe("annotation_1"); + expect(extractedFirst.promptText).toBe("Fix this"); + }); +}); diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts new file mode 100644 index 00000000000..f1723dd93aa --- /dev/null +++ b/apps/web/src/lib/previewAnnotation.ts @@ -0,0 +1,111 @@ +import type { PreviewAnnotationPayload } from "@t3tools/contracts"; +import { buildElementContextBlock, normalizeElementContextSelection } from "./elementContext"; + +const TRAILING_PREVIEW_ANNOTATION_BLOCK_PATTERN = + /\n*<preview_annotation>\n((?:(?!<preview_annotation>)[\s\S])*)\n<\/preview_annotation>\s*$/; + +export interface ParsedPreviewAnnotation { + id: string; + title: string; + comment: string; + targetSummary: string; + styleChanges: string[]; + hasScreenshot: boolean; +} + +export interface ExtractedPreviewAnnotation { + promptText: string; + annotation: ParsedPreviewAnnotation | null; +} + +export function buildPreviewAnnotationPrompt(annotation: PreviewAnnotationPayload): string { + const lines = ["Preview annotation:"]; + lines.push(`Id: ${annotation.id}`); + const title = annotation.pageTitle?.trim() || annotation.pageUrl.trim() || "Preview"; + lines.push(`Page: ${title}`); + if (annotation.comment.trim()) lines.push(`Comment: ${annotation.comment.trim()}`); + const targets: string[] = []; + if (annotation.elements.length > 0) { + targets.push( + `${annotation.elements.length} selected element${annotation.elements.length === 1 ? "" : "s"}`, + ); + } + if (annotation.regions.length > 0) { + targets.push( + `${annotation.regions.length} marked region${annotation.regions.length === 1 ? "" : "s"}`, + ); + } + if (annotation.strokes.length > 0) { + targets.push( + `${annotation.strokes.length} drawing${annotation.strokes.length === 1 ? "" : "s"}`, + ); + } + if (targets.length > 0) lines.push(`Targets: ${targets.join(", ")}.`); + if (annotation.styleChanges.length > 0) { + lines.push("Requested visual changes:"); + for (const change of annotation.styleChanges) { + lines.push(`- ${change.property}: ${change.previousValue || "(unset)"} → ${change.value}`); + } + } + if (annotation.screenshot) { + lines.push("The attached screenshot is the annotated preview crop."); + } + const elementContexts = annotation.elements + .map((target) => normalizeElementContextSelection(target.element)) + .filter((context) => context !== null); + const elementBlock = buildElementContextBlock(elementContexts); + if (elementBlock) lines.push(elementBlock); + return ["<preview_annotation>", ...lines, "</preview_annotation>"].join("\n"); +} + +export function appendPreviewAnnotationPrompt( + prompt: string, + annotation: PreviewAnnotationPayload, +): string { + const annotationText = buildPreviewAnnotationPrompt(annotation); + const trimmed = prompt.trim(); + return trimmed ? `${trimmed}\n\n${annotationText}` : annotationText; +} + +export function extractTrailingPreviewAnnotation(prompt: string): ExtractedPreviewAnnotation { + const match = TRAILING_PREVIEW_ANNOTATION_BLOCK_PATTERN.exec(prompt); + if (!match) return { promptText: prompt, annotation: null }; + const body = match[1] ?? ""; + const lines = body.split("\n"); + const pageLine = lines.find((line) => line.startsWith("Page: ")); + const idLine = lines.find((line) => line.startsWith("Id: ")); + const commentLine = lines.find((line) => line.startsWith("Comment: ")); + const targetsLine = lines.find((line) => line.startsWith("Targets: ")); + const styleHeadingIndex = lines.indexOf("Requested visual changes:"); + const linesAfterStyleHeading = lines.slice(styleHeadingIndex + 1); + const elementContextIndex = linesAfterStyleHeading.indexOf("<element_context>"); + const styleChanges = + styleHeadingIndex < 0 + ? [] + : linesAfterStyleHeading + .slice(0, elementContextIndex < 0 ? undefined : elementContextIndex) + .filter((line) => line.startsWith("- ")) + .map((line) => line.slice(2)); + return { + promptText: prompt.slice(0, match.index).replace(/\n+$/, ""), + annotation: { + id: idLine?.slice("Id: ".length).trim() || `${match.index}`, + title: pageLine?.slice("Page: ".length).trim() || "Preview annotation", + comment: commentLine?.slice("Comment: ".length).trim() || "", + targetSummary: targetsLine?.slice("Targets: ".length).trim() || "", + styleChanges, + hasScreenshot: body.includes("The attached screenshot is the annotated preview crop."), + }, + }; +} + +export async function previewAnnotationScreenshotFile( + annotation: PreviewAnnotationPayload, +): Promise<File | null> { + if (!annotation.screenshot) return null; + const response = await fetch(annotation.screenshot.dataUrl); + const blob = await response.blob(); + return new File([blob], `preview-annotation-${annotation.id}.png`, { + type: blob.type || "image/png", + }); +} diff --git a/apps/web/src/lib/previewFocus.ts b/apps/web/src/lib/previewFocus.ts new file mode 100644 index 00000000000..af7e76eaf03 --- /dev/null +++ b/apps/web/src/lib/previewFocus.ts @@ -0,0 +1,15 @@ +/** + * Returns true when the user's keyboard focus is somewhere inside the + * preview panel (URL bar, chrome buttons, or — once detected via Electron + * `<webview>` focus events — the embedded page). + * + * Used by the global keybinding handler to gate `preview.refresh` and + * `preview.focusUrl` to only fire while the preview owns focus. + */ +export function isPreviewFocused(): boolean { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) return false; + if (!activeElement.isConnected) return false; + if (activeElement.tagName.toLowerCase() === "webview") return true; + return activeElement.closest("[data-preview-panel-mode]") !== null; +} diff --git a/apps/web/src/lib/processDiagnosticsState.ts b/apps/web/src/lib/processDiagnosticsState.ts deleted file mode 100644 index 7e1b3d698a6..00000000000 --- a/apps/web/src/lib/processDiagnosticsState.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { - ServerProcessDiagnosticsResult, - ServerProcessResourceHistoryResult, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; - -import { ensureLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const PROCESS_DIAGNOSTICS_STALE_TIME_MS = 2_000; -const PROCESS_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; -const PROCESS_RESOURCE_HISTORY_STALE_TIME_MS = 5_000; -const PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR = ":"; - -const processDiagnosticsAtom = Atom.make( - Effect.promise(() => ensureLocalApi().server.getProcessDiagnostics()), -).pipe( - Atom.swr({ - staleTime: PROCESS_DIAGNOSTICS_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel("process-diagnostics"), -); - -function formatProcessResourceHistoryKey(input: { - readonly windowMs: number; - readonly bucketMs: number; -}): string { - return `${input.windowMs}${PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR}${input.bucketMs}`; -} - -function parseProcessResourceHistoryKey(key: string): { - readonly windowMs: number; - readonly bucketMs: number; -} { - const [windowMs = "0", bucketMs = "0"] = key.split(PROCESS_RESOURCE_HISTORY_INPUT_SEPARATOR); - return { - windowMs: Number(windowMs), - bucketMs: Number(bucketMs), - }; -} - -const processResourceHistoryAtom = Atom.family((key: string) => { - const input = parseProcessResourceHistoryKey(key); - return Atom.make( - Effect.promise(() => ensureLocalApi().server.getProcessResourceHistory(input)), - ).pipe( - Atom.swr({ - staleTime: PROCESS_RESOURCE_HISTORY_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(PROCESS_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel(`process-resource-history:${key}`), - ); -}); - -export interface ProcessDiagnosticsState { - readonly data: ServerProcessDiagnosticsResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -export interface ProcessResourceHistoryState { - readonly data: ServerProcessResourceHistoryResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function formatProcessDiagnosticsError(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load process diagnostics."; -} - -function readProcessDiagnosticsError( - result: AsyncResult.AsyncResult<ServerProcessDiagnosticsResult, unknown>, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatProcessDiagnosticsError(squashed); -} - -function readProcessResourceHistoryError( - result: AsyncResult.AsyncResult<ServerProcessResourceHistoryResult, unknown>, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatProcessDiagnosticsError(squashed); -} - -export function refreshProcessDiagnostics(): void { - appAtomRegistry.refresh(processDiagnosticsAtom); -} - -export function useProcessDiagnostics(): ProcessDiagnosticsState { - const result = useAtomValue(processDiagnosticsAtom); - const data = Option.getOrNull(AsyncResult.value(result)); - const refresh = useCallback(() => { - refreshProcessDiagnostics(); - }, []); - - return { - data, - error: readProcessDiagnosticsError(result), - isPending: result.waiting, - refresh, - }; -} - -export function useProcessResourceHistory(input: { - readonly windowMs: number; - readonly bucketMs: number; -}): ProcessResourceHistoryState { - const atom = processResourceHistoryAtom(formatProcessResourceHistoryKey(input)); - const result = useAtomValue(atom); - const data = Option.getOrNull(AsyncResult.value(result)); - - const refresh = useCallback(() => { - appAtomRegistry.refresh(atom); - }, [atom]); - - return { - data, - error: readProcessResourceHistoryError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/web/src/lib/projectPaths.ts b/apps/web/src/lib/projectPaths.ts index da0233ccfb3..262095c663c 100644 --- a/apps/web/src/lib/projectPaths.ts +++ b/apps/web/src/lib/projectPaths.ts @@ -14,4 +14,4 @@ export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch, resolveProjectPathForDispatch, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/state/projects"; diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index 1d7903ced06..ec51d36ea51 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -2,8 +2,9 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; import type * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { FetchHttpClient } from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; -import { remoteHttpClientLayer } from "@t3tools/client-runtime"; +import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; import { makeRelayClientTracingLayer } from "@t3tools/shared/relayTracing"; import { @@ -13,22 +14,22 @@ import { import { primaryEnvironmentRequestInit } from "../environments/primary/requestInit"; import { browserCryptoLayer } from "../cloud/dpop"; -import { webManagedRelayClientLayer } from "../cloud/managedRelayLayer"; +import { managedRelayClientLayer } from "../cloud/managedRelayLayer"; import { resolveCloudPublicConfig, resolveRelayTracingConfig } from "../cloud/publicConfig"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relayUrl ?? "http://relay.invalid"; } -const webHttpClientLayer = remoteHttpClientLayer(globalThis.fetch); -const webRelayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { +const httpClientLayer = remoteHttpClientLayer(globalThis.fetch); +const relayTracingLayer = makeRelayClientTracingLayer(resolveRelayTracingConfig(), { serviceName: "t3-web-relay-client", serviceVersion: import.meta.env.APP_VERSION, runtime: "browser", client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", -}).pipe(Layer.provide(webHttpClientLayer)); +}).pipe(Layer.provide(httpClientLayer)); -export const remoteHttpRuntime = ManagedRuntime.make(webHttpClientLayer); +export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); const primaryHttpRuntime = ManagedRuntime.make( primaryEnvironmentHttpClientLive.pipe( @@ -58,13 +59,16 @@ export function __setPrimaryHttpRunnerForTests(runner?: PrimaryHttpEffectRunner) primaryHttpRunner = runner ?? livePrimaryHttpRunner; } -export const webRuntime = ManagedRuntime.make( - Layer.mergeAll( - webHttpClientLayer, - browserCryptoLayer, - webManagedRelayClientLayer(configuredRelayUrl()).pipe( - Layer.provide(Layer.mergeAll(webHttpClientLayer, browserCryptoLayer)), - Layer.provideMerge(webRelayTracingLayer), - ), +export const runtimeLayer = Layer.mergeAll( + httpClientLayer, + browserCryptoLayer, + Socket.layerWebSocketConstructorGlobal, + relayTracingLayer, + managedRelayClientLayer(configuredRelayUrl()).pipe( + Layer.provide(Layer.mergeAll(httpClientLayer, browserCryptoLayer)), ), ); + +export const runtime = ManagedRuntime.make(runtimeLayer); + +export const runtimeContextLayer = Layer.effectContext(runtime.contextEffect); diff --git a/apps/web/src/lib/sourceControlActions.ts b/apps/web/src/lib/sourceControlActions.ts index 917b8c3a9b2..2d857c8e4b7 100644 --- a/apps/web/src/lib/sourceControlActions.ts +++ b/apps/web/src/lib/sourceControlActions.ts @@ -1,477 +1,10 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsActionOperation, - type VcsActionState, - EMPTY_VCS_ACTION_ATOM, - EMPTY_VCS_ACTION_STATE, - createVcsActionManager, - getVcsActionTargetKey, - vcsActionStateAtom, -} from "@t3tools/client-runtime"; -import { - type EnvironmentId, - type GitActionProgressEvent, - type GitRunStackedActionResult, - type GitStackedAction, - type GitResolvePullRequestResult, - type SourceControlCloneProtocol, - type SourceControlPublishRepositoryResult, - type SourceControlRepositoryVisibility, - type ThreadId, - type VcsPullResult, -} from "@t3tools/contracts"; -import { - useCallback, - useEffect, - useMemo, - useState, - useSyncExternalStore, - useTransition, -} from "react"; - -import { ensureEnvironmentApi } from "../environmentApi"; -import { readEnvironmentConnection } from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { getVcsStatusSnapshot, refreshVcsStatus } from "./vcsStatusState"; -import { vcsRefManager } from "./vcsRefState"; - -type SourceControlActionKind = - | "init" - | "pull" - | "publishRepository" - | "runStackedAction" - | "preparePullRequestThread"; - -interface SourceControlActionScope { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -interface SourceControlActionState<TArgs extends ReadonlyArray<unknown>, TResult> { - readonly isPending: boolean; - readonly error: unknown; - readonly run: (...args: TArgs) => Promise<TResult>; - readonly resetError: () => void; -} - -export const vcsActionManager = createVcsActionManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const client = readEnvironmentConnection(environmentId)?.client; - return client ? { ...client.vcs, runChangeRequest: client.git.runStackedAction } : null; - }, - onInvalidate: (target) => invalidateSourceControlState(target), -}); - -const actionListeners = new Set<() => void>(); -const activeActionCounts = new Map<string, number>(); - -function notifyActionListeners(): void { - for (const listener of actionListeners) { - listener(); - } -} - -function subscribeActionState(listener: () => void): () => void { - actionListeners.add(listener); - return () => { - actionListeners.delete(listener); - }; -} - -function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string { - return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`; -} - -function beginAction(key: string): () => void { - activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1); - notifyActionListeners(); - let ended = false; - return () => { - if (ended) { - return; - } - ended = true; - const next = (activeActionCounts.get(key) ?? 1) - 1; - if (next <= 0) { - activeActionCounts.delete(key); - } else { - activeActionCounts.set(key, next); - } - notifyActionListeners(); - }; -} - -function isAnyActionRunning( - kinds: ReadonlyArray<SourceControlActionKind>, - scope: SourceControlActionScope, -): boolean { - return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0); -} - -function getVcsActionOperationForKind(kind: SourceControlActionKind): VcsActionOperation | null { - switch (kind) { - case "init": - return "init"; - case "pull": - return "pull"; - case "runStackedAction": - return "run_change_request"; - case "publishRepository": - case "preparePullRequestThread": - return null; - } -} - -function useVcsActionStateForScope(scope: SourceControlActionScope): VcsActionState { - const targetKey = getVcsActionTargetKey(scope); - const state = useAtomValue( - targetKey !== null ? vcsActionStateAtom(targetKey) : EMPTY_VCS_ACTION_ATOM, - ); - return targetKey === null ? EMPTY_VCS_ACTION_STATE : state; -} - -export function invalidateSourceControlState(scope?: { - readonly environmentId?: EnvironmentId | null; - readonly cwd?: string | null; -}): Promise<void> { - const environmentId = scope?.environmentId ?? null; - const cwd = scope?.cwd ?? null; - if (cwd !== null) { - vcsRefManager.invalidateScope({ environmentId, cwd }); - if (environmentId !== null) { - return refreshVcsStatus({ environmentId, cwd }).then( - () => undefined, - () => undefined, - ); - } - return Promise.resolve(); - } - - vcsRefManager.invalidate(); - return Promise.resolve(); -} - -function useSourceControlAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: { - readonly kind: SourceControlActionKind; - readonly scope: SourceControlActionScope; - readonly action: (...args: TArgs) => Promise<TResult>; - readonly invalidateOnSuccess?: boolean; -}): SourceControlActionState<TArgs, TResult> { - const { action, invalidateOnSuccess = true, kind, scope } = input; - const [error, setError] = useState<unknown>(null); - const [activeCount, setActiveCount] = useState(0); - const [isTransitionPending, startTransition] = useTransition(); - const key = actionKey(kind, scope); - - const resetError = useCallback(() => { - startTransition(() => setError(null)); - }, [startTransition]); - - const run = useCallback( - async (...args: TArgs): Promise<TResult> => { - const endAction = beginAction(key); - startTransition(() => { - setError(null); - setActiveCount((count) => count + 1); - }); - try { - const result = await action(...args); - if (invalidateOnSuccess) { - await invalidateSourceControlState(scope); - } - return result; - } catch (nextError) { - startTransition(() => setError(nextError)); - throw nextError; - } finally { - endAction(); - startTransition(() => setActiveCount((count) => Math.max(0, count - 1))); - } - }, - [action, invalidateOnSuccess, key, scope, startTransition], - ); - - return { - error, - isPending: activeCount > 0 || isTransitionPending, - resetError, - run, - }; -} - -export function useSourceControlActionRunning( - scope: SourceControlActionScope, - kinds: ReadonlyArray<SourceControlActionKind>, -): boolean { - const stableKinds = useMemo(() => kinds.toSorted(), [kinds]); - const appActionRunning = useSyncExternalStore( - subscribeActionState, - () => isAnyActionRunning(stableKinds, scope), - () => false, - ); - const vcsActionState = useVcsActionStateForScope(scope); - const vcsActionRunning = - vcsActionState.isRunning && - stableKinds.some((kind) => getVcsActionOperationForKind(kind) === vcsActionState.operation); - - return appActionRunning || vcsActionRunning; -} - -function useVcsManagerAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: { - readonly operation: VcsActionOperation; - readonly scope: SourceControlActionScope; - readonly unavailableMessage: string; - readonly action: (...args: TArgs) => Promise<TResult | null>; -}): SourceControlActionState<TArgs, TResult> { - const { action, operation, scope, unavailableMessage } = input; - const vcsActionState = useVcsActionStateForScope(scope); - const [error, setError] = useState<unknown>(null); - const [isTransitionPending, startTransition] = useTransition(); - - const resetError = useCallback(() => { - vcsActionManager.reset(scope); - startTransition(() => setError(null)); - }, [scope, startTransition]); - - const run = useCallback( - async (...args: TArgs): Promise<TResult> => { - startTransition(() => setError(null)); - try { - const result = await action(...args); - if (result === null) { - throw new Error(unavailableMessage); - } - return result; - } catch (nextError) { - startTransition(() => setError(nextError)); - throw nextError; - } - }, - [action, startTransition, unavailableMessage], - ); - - return { - error: error ?? vcsActionState.error, - isPending: - isTransitionPending || (vcsActionState.isRunning && vcsActionState.operation === operation), - resetError, - run, - }; -} - -export function useVcsInitAction(scope: SourceControlActionScope) { - const action = useCallback(async () => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git init is unavailable."); - return vcsActionManager.init(scope); - }, [scope]); - - return useVcsManagerAction({ - operation: "init", - scope, - unavailableMessage: "Git init is unavailable.", - action, - }); -} - -export function useGitStackedAction(scope: SourceControlActionScope) { - const action = useCallback( - async ({ - actionId, - action, - commitMessage, - featureBranch, - filePaths, - onProgress, - }: { - actionId: string; - action: GitStackedAction; - commitMessage?: string; - featureBranch?: boolean; - filePaths?: string[]; - onProgress?: (event: GitActionProgressEvent) => void; - }): Promise<GitRunStackedActionResult | null> => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git action is unavailable."); - return vcsActionManager.runChangeRequest( - scope, - { - actionId, - action, - ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch: true } : {}), - ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), - }, - { - gitStatus: getVcsStatusSnapshot(scope).data, - ...(onProgress ? { onProgress } : {}), - }, - ); - }, - [scope], - ); - - return useVcsManagerAction({ - operation: "run_change_request", - scope, - unavailableMessage: "Git action is unavailable.", - action, - }); -} - -export function useVcsPullAction(scope: SourceControlActionScope) { - const action = useCallback(async (): Promise<VcsPullResult | null> => { - if (!scope.cwd || !scope.environmentId) throw new Error("Git pull is unavailable."); - return vcsActionManager.pull(scope); - }, [scope]); - - return useVcsManagerAction({ - operation: "pull", - scope, - unavailableMessage: "Git pull is unavailable.", - action, - }); -} - -export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { - const action = useCallback( - async (args: { - provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; - repository: string; - visibility: SourceControlRepositoryVisibility; - remoteName: string; - protocol: SourceControlCloneProtocol; - }): Promise<SourceControlPublishRepositoryResult> => { - if (!scope.cwd || !scope.environmentId) { - throw new Error("Repository publishing is unavailable."); - } - return ensureEnvironmentApi(scope.environmentId).sourceControl.publishRepository({ - cwd: scope.cwd, - ...args, - }); - }, - [scope], - ); - - return useSourceControlAction({ - kind: "publishRepository", - scope, - action, - }); -} - -export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { - const action = useCallback( - async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { - if (!scope.cwd || !scope.environmentId) { - throw new Error("Pull request thread preparation is unavailable."); - } - return ensureEnvironmentApi(scope.environmentId).git.preparePullRequestThread({ - cwd: scope.cwd, - reference: args.reference, - mode: args.mode, - ...(args.threadId ? { threadId: args.threadId } : {}), - }); - }, - [scope], - ); - - return useSourceControlAction({ - kind: "preparePullRequestThread", - scope, - action, - }); -} - -interface PullRequestResolutionTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly reference: string | null; -} - -interface PullRequestResolutionState { - readonly data: GitResolvePullRequestResult | null; - readonly error: unknown; - readonly isPending: boolean; - readonly isFetching: boolean; -} - -const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = { - data: null, - error: null, - isPending: false, - isFetching: false, -}; - -const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>(); - -function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null { - if (!target.environmentId || !target.cwd || !target.reference) { - return null; - } - return `${target.environmentId}:${target.cwd}:${target.reference}`; -} - -export function readCachedPullRequestResolution( - target: PullRequestResolutionTarget, -): GitResolvePullRequestResult | null { - const key = pullRequestResolutionKey(target); - return key ? (pullRequestResolutionCache.get(key) ?? null) : null; -} - -export function usePullRequestResolution( - target: PullRequestResolutionTarget, -): PullRequestResolutionState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - reference: target.reference, - }), - [target.cwd, target.environmentId, target.reference], - ); - const key = pullRequestResolutionKey(stableTarget); - const [state, setState] = useState<PullRequestResolutionState>(() => { - const cached = readCachedPullRequestResolution(stableTarget); - return cached - ? { data: cached, error: null, isPending: false, isFetching: false } - : EMPTY_PULL_REQUEST_RESOLUTION; - }); - - useEffect(() => { - if (!key || !stableTarget.environmentId || !stableTarget.cwd || !stableTarget.reference) { - setState(EMPTY_PULL_REQUEST_RESOLUTION); - return; - } - - const cached = pullRequestResolutionCache.get(key) ?? null; - setState({ - data: cached, - error: null, - isPending: cached === null, - isFetching: true, - }); - - let cancelled = false; - ensureEnvironmentApi(stableTarget.environmentId) - .git.resolvePullRequest({ cwd: stableTarget.cwd, reference: stableTarget.reference }) - .then((result) => { - if (cancelled) { - return; - } - pullRequestResolutionCache.set(key, result); - setState({ data: result, error: null, isPending: false, isFetching: false }); - }) - .catch((error: unknown) => { - if (cancelled) { - return; - } - setState({ data: cached, error, isPending: false, isFetching: false }); - }); - - return () => { - cancelled = true; - }; - }, [key, stableTarget]); - - return state; -} +export { + readCachedPullRequestResolution, + useGitStackedAction, + usePreparePullRequestThreadAction, + usePullRequestResolutionState as usePullRequestResolution, + useSourceControlActionRunning, + useSourceControlPublishRepositoryAction, + useVcsInitAction, + useVcsPullAction, +} from "../state/sourceControlActions"; diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts deleted file mode 100644 index 133f09d252a..00000000000 --- a/apps/web/src/lib/sourceControlDiscoveryState.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type SourceControlDiscoveryTarget, - type SourceControlDiscoveryState, - createSourceControlDiscoveryManager, - getSourceControlDiscoveryTargetKey, - sourceControlDiscoveryStateAtom, -} from "@t3tools/client-runtime"; -import { EnvironmentId, type SourceControlDiscoveryResult } from "@t3tools/contracts"; -import { useEffect } from "react"; - -import { readPrimaryEnvironmentDescriptor } from "../environments/primary"; -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { readLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; -const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; -const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; - -interface SourceControlDiscoveryTargetInput { - readonly environmentId?: EnvironmentId | null; -} - -function sourceControlDiscoveryTarget( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryTarget { - const environmentId = input?.environmentId ?? null; - if (!environmentId) { - return SOURCE_CONTROL_DISCOVERY_TARGET; - } - return readPrimaryEnvironmentDescriptor()?.environmentId === environmentId - ? SOURCE_CONTROL_DISCOVERY_TARGET - : { key: environmentId }; -} - -const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ - getRegistry: () => appAtomRegistry, - getClient: (key) => { - if (key === SOURCE_CONTROL_DISCOVERY_TARGET.key) { - const primaryEnvironmentId = readPrimaryEnvironmentDescriptor()?.environmentId ?? null; - const primaryConnection = primaryEnvironmentId - ? readEnvironmentConnection(primaryEnvironmentId) - : null; - if (primaryConnection) { - return primaryConnection.client.server; - } - try { - return readLocalApi()?.server ?? null; - } catch { - return null; - } - } - const environmentId = EnvironmentId.make(key); - const connection = readEnvironmentConnection(environmentId); - if (connection) { - return connection.client.server; - } - return null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, - staleTimeMs: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, - idleTtlMs: SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS, -}); - -export function refreshSourceControlDiscovery( - input?: SourceControlDiscoveryTargetInput, -): Promise<SourceControlDiscoveryResult | null> { - return sourceControlDiscoveryManager.refresh(sourceControlDiscoveryTarget(input)); -} - -export function getSourceControlDiscoverySnapshot( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryState { - return sourceControlDiscoveryManager.getSnapshot(sourceControlDiscoveryTarget(input)); -} - -export function resetSourceControlDiscoveryStateForTests(): void { - sourceControlDiscoveryManager.reset(); -} - -export function useSourceControlDiscovery( - input?: SourceControlDiscoveryTargetInput, -): SourceControlDiscoveryState { - const targetKey = - getSourceControlDiscoveryTargetKey(sourceControlDiscoveryTarget(input)) ?? - SOURCE_CONTROL_DISCOVERY_TARGET.key; - - useEffect(() => sourceControlDiscoveryManager.watch({ key: targetKey }), [targetKey]); - - return useAtomValue(sourceControlDiscoveryStateAtom(targetKey)); -} diff --git a/apps/web/src/lib/terminalContext.test.ts b/apps/web/src/lib/terminalContext.test.ts index 3215ffc8615..4b520c9bef4 100644 --- a/apps/web/src/lib/terminalContext.test.ts +++ b/apps/web/src/lib/terminalContext.test.ts @@ -122,6 +122,7 @@ describe("terminalContext", () => { body: "12 | git status\n13 | On branch main", }, ], + elementContexts: [], }); }); diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index ba63fd5a02b..72f49a2f22d 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -1,5 +1,7 @@ import { type ThreadId } from "@t3tools/contracts"; +import { extractTrailingElementContexts, type ParsedElementContextEntry } from "./elementContext"; + export interface TerminalContextSelection { terminalId: string; terminalLabel: string; @@ -27,6 +29,12 @@ export interface DisplayedUserMessageState { contextCount: number; previewTitle: string | null; contexts: ParsedTerminalContextEntry[]; + /** + * Element-context entries extracted from the trailing `<element_context>` + * block (if any). Stripped from `visibleText` so the raw block doesn't + * leak into the user's bubble. + */ + elementContexts: ParsedElementContextEntry[]; } export interface ParsedTerminalContextEntry { @@ -238,13 +246,18 @@ export function extractTrailingTerminalContexts(prompt: string): ExtractedTermin } export function deriveDisplayedUserMessageState(prompt: string): DisplayedUserMessageState { - const extractedContexts = extractTrailingTerminalContexts(prompt); + // Order matters: send-time appends `<terminal_context>` first, then + // `<element_context>` last. Strip element first so the (now-trailing) + // terminal block can be matched by `extractTrailingTerminalContexts`. + const extractedElement = extractTrailingElementContexts(prompt); + const extractedTerminal = extractTrailingTerminalContexts(extractedElement.promptText); return { - visibleText: extractedContexts.promptText, + visibleText: extractedTerminal.promptText, copyText: prompt, - contextCount: extractedContexts.contextCount, - previewTitle: extractedContexts.previewTitle, - contexts: extractedContexts.contexts, + contextCount: extractedTerminal.contextCount, + previewTitle: extractedTerminal.previewTitle, + contexts: extractedTerminal.contexts, + elementContexts: extractedElement.contexts, }; } diff --git a/apps/web/src/lib/terminalFocus.test.ts b/apps/web/src/lib/terminalFocus.test.ts index fa6c7a5c954..83f26637d4e 100644 --- a/apps/web/src/lib/terminalFocus.test.ts +++ b/apps/web/src/lib/terminalFocus.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it } from "vite-plus/test"; -import { isTerminalFocused } from "./terminalFocus"; +import { getTerminalFocusOwner, isTerminalFocused } from "./terminalFocus"; class MockHTMLElement { isConnected = false; className = ""; + terminalOwner: string | null = null; + readonly dataset: { terminalOwner?: string } = {}; readonly classList = { contains: (value: string) => this.className.split(/\s+/).includes(value), @@ -14,7 +16,7 @@ class MockHTMLElement { if (!this.isConnected) { return null; } - if (selector === ".thread-terminal-drawer .xterm" || selector === ".thread-terminal-drawer") { + if (selector === "[data-terminal-owner]" && this.terminalOwner !== null) { return this; } return null; @@ -44,30 +46,36 @@ describe("isTerminalFocused", () => { detached.className = "xterm-helper-textarea"; globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; - globalThis.document = { activeElement: detached } as Document; + globalThis.document = { activeElement: detached } as unknown as Document; expect(isTerminalFocused()).toBe(false); }); - it("returns true for connected xterm helper textareas", () => { + it("returns the drawer owner for connected xterm helper textareas", () => { const attached = new MockHTMLElement(); attached.className = "xterm-helper-textarea"; attached.isConnected = true; + attached.terminalOwner = "drawer"; + attached.dataset.terminalOwner = "drawer"; globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; - globalThis.document = { activeElement: attached } as Document; + globalThis.document = { activeElement: attached } as unknown as Document; + expect(getTerminalFocusOwner()).toBe("drawer"); expect(isTerminalFocused()).toBe(true); }); - it("returns true for focus inside the terminal drawer (e.g. sidebar)", () => { + it("returns the right panel owner for focus inside its terminal UI", () => { const sidebarButton = new MockHTMLElement(); sidebarButton.className = "terminal-sidebar-button"; sidebarButton.isConnected = true; + sidebarButton.terminalOwner = "right-panel"; + sidebarButton.dataset.terminalOwner = "right-panel"; globalThis.HTMLElement = MockHTMLElement as unknown as typeof HTMLElement; - globalThis.document = { activeElement: sidebarButton } as Document; + globalThis.document = { activeElement: sidebarButton } as unknown as Document; + expect(getTerminalFocusOwner()).toBe("right-panel"); expect(isTerminalFocused()).toBe(true); }); }); diff --git a/apps/web/src/lib/terminalFocus.ts b/apps/web/src/lib/terminalFocus.ts index 61b969c2b82..158c7fb2c98 100644 --- a/apps/web/src/lib/terminalFocus.ts +++ b/apps/web/src/lib/terminalFocus.ts @@ -1,9 +1,14 @@ -export function isTerminalFocused(): boolean { +export type TerminalFocusOwner = "drawer" | "right-panel"; + +export function getTerminalFocusOwner(): TerminalFocusOwner | null { const activeElement = document.activeElement; - if (!(activeElement instanceof HTMLElement)) return false; - if (!activeElement.isConnected) return false; - if (activeElement.classList.contains("xterm-helper-textarea")) return true; - if (activeElement.closest(".thread-terminal-drawer .xterm") !== null) return true; - // Sidebar / toolbar / resize affordances: still "terminal UI" for split vs diff.toggle (⌘D). - return activeElement.closest(".thread-terminal-drawer") !== null; + if (!(activeElement instanceof HTMLElement)) return null; + if (!activeElement.isConnected) return null; + const owner = activeElement.closest<HTMLElement>("[data-terminal-owner]")?.dataset.terminalOwner; + if (owner === "drawer" || owner === "right-panel") return owner; + return null; +} + +export function isTerminalFocused(): boolean { + return getTerminalFocusOwner() !== null; } diff --git a/apps/web/src/lib/terminalUiStateCleanup.test.ts b/apps/web/src/lib/terminalUiStateCleanup.test.ts index f96436fc976..a7fa1c1d317 100644 --- a/apps/web/src/lib/terminalUiStateCleanup.test.ts +++ b/apps/web/src/lib/terminalUiStateCleanup.test.ts @@ -1,4 +1,4 @@ -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index ad4126e4b30..b9981bc2e3e 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -16,7 +16,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: LOCAL_ENVIRONMENT_ID, - codexThreadId: null, projectId: PROJECT_ID, title: "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, @@ -25,14 +24,14 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { session: null, messages: [], proposedPlans: [], - error: null, createdAt: "2026-03-09T10:00:00.000Z", archivedAt: null, + deletedAt: null, updatedAt: "2026-03-09T10:00:00.000Z", latestTurn: null, branch: null, worktreePath: null, - turnDiffSummaries: [], + checkpoints: [], activities: [], ...overrides, }; @@ -50,9 +49,10 @@ describe("sortThreads", () => { id: "message-1" as never, role: "user", text: "older", + turnId: null, createdAt: "2026-03-09T10:01:00.000Z", + updatedAt: "2026-03-09T10:01:00.000Z", streaming: false, - completedAt: "2026-03-09T10:01:00.000Z", }, ], }), @@ -65,9 +65,10 @@ describe("sortThreads", () => { id: "message-2" as never, role: "user", text: "newer", + turnId: null, createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "2026-03-09T10:06:00.000Z", streaming: false, - completedAt: "2026-03-09T10:06:00.000Z", }, ], }), @@ -92,9 +93,10 @@ describe("sortThreads", () => { id: "message-1" as never, role: "assistant", text: "assistant only", + turnId: null, createdAt: "2026-03-09T10:02:00.000Z", + updatedAt: "2026-03-09T10:02:00.000Z", streaming: false, - completedAt: "2026-03-09T10:02:00.000Z", }, ], }), @@ -144,14 +146,14 @@ describe("sortThreads", () => { [ makeThread({ id: ThreadId.make("thread-1"), - createdAt: "" as never, - updatedAt: undefined, + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, messages: [], }), makeThread({ id: ThreadId.make("thread-2"), - createdAt: "" as never, - updatedAt: undefined, + createdAt: "invalid-created-at" as never, + updatedAt: "invalid-updated-at" as never, messages: [], }), ], diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index de8b22e93c5..9dc31cbbca5 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -4,7 +4,7 @@ import type { Thread } from "../types"; export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & { latestUserMessageAt?: string | null; - messages?: Pick<Thread["messages"][number], "createdAt" | "role">[]; + messages?: ReadonlyArray<Pick<Thread["messages"][number], "createdAt" | "role">>; }; export function toSortableTimestamp(iso: string | undefined): number | null { diff --git a/apps/web/src/lib/traceDiagnosticsState.ts b/apps/web/src/lib/traceDiagnosticsState.ts deleted file mode 100644 index 73d9a6c3949..00000000000 --- a/apps/web/src/lib/traceDiagnosticsState.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import type { ServerTraceDiagnosticsResult } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback } from "react"; - -import { ensureLocalApi } from "../localApi"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const TRACE_DIAGNOSTICS_STALE_TIME_MS = 5_000; -const TRACE_DIAGNOSTICS_IDLE_TTL_MS = 5 * 60_000; - -const traceDiagnosticsAtom = Atom.make( - Effect.promise(() => ensureLocalApi().server.getTraceDiagnostics()), -).pipe( - Atom.swr({ - staleTime: TRACE_DIAGNOSTICS_STALE_TIME_MS, - revalidateOnMount: true, - }), - Atom.setIdleTTL(TRACE_DIAGNOSTICS_IDLE_TTL_MS), - Atom.withLabel("trace-diagnostics"), -); - -export interface TraceDiagnosticsState { - readonly data: ServerTraceDiagnosticsResult | null; - readonly error: string | null; - readonly isPending: boolean; - readonly refresh: () => void; -} - -function formatTraceDiagnosticsError(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load trace diagnostics."; -} - -function readTraceDiagnosticsError( - result: AsyncResult.AsyncResult<ServerTraceDiagnosticsResult, unknown>, -): string | null { - if (result._tag !== "Failure") { - return null; - } - - const squashed = Cause.squash(result.cause); - return formatTraceDiagnosticsError(squashed); -} - -export function refreshTraceDiagnostics(): void { - appAtomRegistry.refresh(traceDiagnosticsAtom); -} - -export function useTraceDiagnostics(): TraceDiagnosticsState { - const result = useAtomValue(traceDiagnosticsAtom); - const data = Option.getOrNull(AsyncResult.value(result)); - const refresh = useCallback(() => { - refreshTraceDiagnostics(); - }, []); - - return { - data, - error: readTraceDiagnosticsError(result), - isPending: result.waiting, - refresh, - }; -} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts index 555fc71f01f..47b428bc3a2 100644 --- a/apps/web/src/lib/turnDiffTree.test.ts +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -5,9 +5,9 @@ import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; describe("summarizeTurnDiffStats", () => { it("sums only files with numeric additions/deletions", () => { const stat = summarizeTurnDiffStats([ - { path: "README.md", additions: 3, deletions: 1 }, - { path: "docs/notes.md" }, - { path: "src/index.ts", additions: 5, deletions: 2 }, + { path: "README.md", kind: "modified", additions: 3, deletions: 1 }, + { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, + { path: "src/index.ts", kind: "modified", additions: 5, deletions: 2 }, ]); expect(stat).toEqual({ additions: 8, deletions: 3 }); @@ -17,9 +17,9 @@ describe("summarizeTurnDiffStats", () => { describe("buildTurnDiffTree", () => { it("builds nested directory nodes with aggregated stats", () => { const tree = buildTurnDiffTree([ - { path: "src/index.ts", additions: 2, deletions: 1 }, - { path: "src/components/Button.tsx", additions: 4, deletions: 2 }, - { path: "README.md", additions: 1, deletions: 0 }, + { path: "src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "src/components/Button.tsx", kind: "modified", additions: 4, deletions: 2 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, ]); expect(tree).toEqual([ @@ -60,10 +60,10 @@ describe("buildTurnDiffTree", () => { ]); }); - it("keeps files without stat values and excludes them from directory totals", () => { + it("keeps zero-valued file stats and includes only their numeric contribution", () => { const tree = buildTurnDiffTree([ - { path: "docs/notes.md" }, - { path: "docs/todo.md", additions: 1, deletions: 1 }, + { path: "docs/notes.md", kind: "modified", additions: 0, deletions: 0 }, + { path: "docs/todo.md", kind: "modified", additions: 1, deletions: 1 }, ]); expect(tree).toEqual([ @@ -77,7 +77,7 @@ describe("buildTurnDiffTree", () => { kind: "file", name: "notes.md", path: "docs/notes.md", - stat: null, + stat: { additions: 0, deletions: 0 }, }, { kind: "file", @@ -92,7 +92,7 @@ describe("buildTurnDiffTree", () => { it("normalizes file paths with windows separators", () => { const tree = buildTurnDiffTree([ - { path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }, + { path: "apps\\web\\src\\index.ts", kind: "modified", additions: 2, deletions: 1 }, ]); expect(tree).toEqual([ @@ -115,8 +115,8 @@ describe("buildTurnDiffTree", () => { it("compacts only single-directory chains and stops at branch points", () => { const tree = buildTurnDiffTree([ - { path: "apps/server/src/index.ts", additions: 2, deletions: 1 }, - { path: "apps/server/main.ts", additions: 4, deletions: 0 }, + { path: "apps/server/src/index.ts", kind: "modified", additions: 2, deletions: 1 }, + { path: "apps/server/main.ts", kind: "modified", additions: 4, deletions: 0 }, ]); expect(tree).toEqual([ @@ -153,8 +153,8 @@ describe("buildTurnDiffTree", () => { it("preserves leading/trailing whitespace in path segments", () => { const tree = buildTurnDiffTree([ - { path: "a/file.ts", additions: 1, deletions: 0 }, - { path: " a/file.ts", additions: 2, deletions: 0 }, + { path: "a/file.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: " a/file.ts", kind: "modified", additions: 2, deletions: 0 }, ]); expect(tree).toHaveLength(2); diff --git a/apps/web/src/lib/vcsRefState.ts b/apps/web/src/lib/vcsRefState.ts deleted file mode 100644 index 8addc03c5f7..00000000000 --- a/apps/web/src/lib/vcsRefState.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsRefState, - type VcsRefTarget, - EMPTY_VCS_REF_ATOM, - EMPTY_VCS_REF_STATE, - createVcsRefManager, - getVcsRefTargetKey, - vcsRefStateAtom, -} from "@t3tools/client-runtime"; -import { useEffect, useMemo } from "react"; - -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -const VCS_REF_LIST_LIMIT = 100; -const VCS_REF_STALE_TIME_MS = 5_000; - -export const vcsRefManager = createVcsRefManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.vcs ?? null, - subscribeClientChanges: subscribeEnvironmentConnections, - watchLimit: VCS_REF_LIST_LIMIT, - staleTimeMs: VCS_REF_STALE_TIME_MS, - onBackgroundError: (error) => { - console.warn("[vcs-refs] background refresh failed", error); - }, -}); - -export function useVcsRefs(target: VcsRefTarget): VcsRefState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - cwd: target.cwd, - query: target.query ?? null, - }), - [target.cwd, target.environmentId, target.query], - ); - const targetKey = getVcsRefTargetKey(stableTarget); - - useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]); - - const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM); - return targetKey === null ? EMPTY_VCS_REF_STATE : state; -} diff --git a/apps/web/src/lib/vcsStatusState.ts b/apps/web/src/lib/vcsStatusState.ts deleted file mode 100644 index 986f1c5893c..00000000000 --- a/apps/web/src/lib/vcsStatusState.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - type VcsStatusClient, - type VcsStatusState, - type VcsStatusTarget, - EMPTY_VCS_STATUS_ATOM, - EMPTY_VCS_STATUS_STATE, - createVcsStatusManager, - getVcsStatusTargetKey, - vcsStatusStateAtom, -} from "@t3tools/client-runtime"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { useEffect } from "react"; - -import { - readEnvironmentConnection, - subscribeEnvironmentConnections, -} from "../environments/runtime"; -import { appAtomRegistry } from "../rpc/atomRegistry"; - -export type { VcsStatusState, VcsStatusTarget }; - -const manager = createVcsStatusManager({ - getRegistry: () => appAtomRegistry, - getClient: (environmentId) => { - const connection = readEnvironmentConnection(environmentId as EnvironmentId); - return connection ? connection.client.vcs : null; - }, - getClientIdentity: (environmentId) => { - const connection = readEnvironmentConnection(environmentId as EnvironmentId); - return connection ? connection.environmentId : null; - }, - subscribeClientChanges: subscribeEnvironmentConnections, -}); - -export function getVcsStatusSnapshot(target: VcsStatusTarget): VcsStatusState { - return manager.getSnapshot(target); -} - -export function watchVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient): () => void { - return manager.watch(target, client); -} - -export function refreshVcsStatus(target: VcsStatusTarget, client?: VcsStatusClient) { - return manager.refresh(target, client); -} - -export function resetVcsStatusStateForTests(): void { - manager.reset(); -} - -export function useVcsStatus(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - useEffect( - () => manager.watch({ environmentId: target.environmentId, cwd: target.cwd }), - [target.environmentId, target.cwd], - ); - - const state = useAtomValue( - targetKey !== null ? vcsStatusStateAtom(targetKey) : EMPTY_VCS_STATUS_ATOM, - ); - return targetKey === null ? EMPTY_VCS_STATUS_STATE : state; -} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index e50dbd9f5f8..2e8534f1b50 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -1,23 +1,7 @@ -import { - CommandId, - DEFAULT_SERVER_SETTINGS, - type DesktopBridge, - EnvironmentId, - type VcsStatusResult, - ProjectId, - type OrchestrationShellStreamItem, - ProviderDriverKind, - ProviderInstanceId, - type ServerConfig, - type ServerProvider, - type TerminalAttachStreamEvent, - type TerminalMetadataStreamEvent, - ThreadId, -} from "@t3tools/contracts"; +import type { ContextMenuItem, DesktopBridge } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { ContextMenuItem } from "@t3tools/contracts"; - const showContextMenuFallbackMock = vi.fn< <T extends string>( @@ -26,323 +10,44 @@ const showContextMenuFallbackMock = ) => Promise<T | null> >(); -function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const terminalAttachListeners = new Set<(event: TerminalAttachStreamEvent) => void>(); -const terminalMetadataListeners = new Set<(event: TerminalMetadataStreamEvent) => void>(); -const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); -const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); - -const rpcClientMock = { - dispose: vi.fn(), - terminal: { - open: vi.fn(), - attach: vi.fn((_input: unknown, listener: (event: TerminalAttachStreamEvent) => void) => - registerListener(terminalAttachListeners, listener), - ), - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - restart: vi.fn(), - close: vi.fn(), - onMetadata: vi.fn((listener: (event: TerminalMetadataStreamEvent) => void) => - registerListener(terminalMetadataListeners, listener), - ), - }, - projects: { - searchEntries: vi.fn(), - writeFile: vi.fn(), - }, - filesystem: { - browse: vi.fn(), - }, - sourceControl: { - lookupRepository: vi.fn(), - cloneRepository: vi.fn(), - publishRepository: vi.fn(), - }, - shell: { - openInEditor: vi.fn(), - }, - vcs: { - pull: vi.fn(), - refreshStatus: vi.fn(), - onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => - registerListener(gitStatusListeners, listener), - ), - listRefs: vi.fn(), - createWorktree: vi.fn(), - removeWorktree: vi.fn(), - createRef: vi.fn(), - switchRef: vi.fn(), - init: vi.fn(), - }, - git: { - runStackedAction: vi.fn(), - resolvePullRequest: vi.fn(), - preparePullRequestThread: vi.fn(), - }, - review: { - getDiffPreview: vi.fn(), - }, - server: { - getConfig: vi.fn(), - refreshProviders: vi.fn(), - updateProvider: vi.fn(), - upsertKeybinding: vi.fn(), - getSettings: vi.fn(), - updateSettings: vi.fn(), - subscribeConfig: vi.fn(), - subscribeLifecycle: vi.fn(), - subscribeAuthAccess: vi.fn(), - }, - orchestration: { - dispatchCommand: vi.fn(), - getTurnDiff: vi.fn(), - getFullThreadDiff: vi.fn(), - subscribeShell: vi.fn((listener: (event: OrchestrationShellStreamItem) => void) => - registerListener(shellStreamListeners, listener), - ), - subscribeThread: vi.fn(() => () => undefined), - }, -}; - -vi.mock("./environments/runtime", () => ({ - getPrimaryEnvironmentConnection: () => ({ - kind: "primary" as const, - knownEnvironment: { - id: "environment-local", - label: "Primary", - source: "manual" as const, - target: { - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - }, - environmentId: EnvironmentId.make("environment-local"), - }, - client: rpcClientMock, - environmentId: EnvironmentId.make("environment-local"), - ensureBootstrapped: async () => undefined, - reconnect: async () => undefined, - dispose: async () => undefined, - }), - resetEnvironmentServiceForTests: vi.fn(), - resetSavedEnvironmentRegistryStoreForTests: vi.fn(), - resetSavedEnvironmentRuntimeStoreForTests: vi.fn(), - subscribeEnvironmentConnections: vi.fn(() => () => undefined), -})); - vi.mock("./contextMenuFallback", () => ({ showContextMenuFallback: showContextMenuFallbackMock, })); -function emitEvent<T>(listeners: Set<(event: T) => void>, event: T) { - for (const listener of listeners) { - listener(event); - } -} - -function getWindowForTest(): Window & typeof globalThis & { desktopBridge?: unknown } { - const testGlobal = globalThis as typeof globalThis & { - window?: Window & typeof globalThis & { desktopBridge?: unknown }; - }; - if (!testGlobal.window) { - testGlobal.window = {} as Window & typeof globalThis & { desktopBridge?: unknown }; - } - return testGlobal.window; -} - function createLocalStorageStub(): Storage { - const store = new Map<string, string>(); + const values = new Map<string, string>(); return { - getItem: (key) => store.get(key) ?? null, + getItem: (key) => values.get(key) ?? null, setItem: (key, value) => { - store.set(key, value); + values.set(key, value); }, removeItem: (key) => { - store.delete(key); + values.delete(key); }, - clear: () => { - store.clear(); - }, - key: (index) => [...store.keys()][index] ?? null, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, get length() { - return store.size; + return values.size; }, }; } -function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridge { - return { - getAppBranding: () => null, - getLocalEnvironmentBootstrap: () => null, - getClientSettings: async () => null, - setClientSettings: async () => undefined, - getSavedEnvironmentRegistry: async () => [], - setSavedEnvironmentRegistry: async () => undefined, - getSavedEnvironmentSecret: async () => null, - setSavedEnvironmentSecret: async () => true, - removeSavedEnvironmentSecret: async () => undefined, - discoverSshHosts: async () => [], - ensureSshEnvironment: async () => { - throw new Error("ensureSshEnvironment not implemented in test"); - }, - disconnectSshEnvironment: async () => undefined, - fetchSshEnvironmentDescriptor: async () => { - throw new Error("fetchSshEnvironmentDescriptor not implemented in test"); - }, - bootstrapSshBearerSession: async () => { - throw new Error("bootstrapSshBearerSession not implemented in test"); - }, - fetchSshSessionState: async () => { - throw new Error("fetchSshSessionState not implemented in test"); - }, - issueSshWebSocketTicket: async () => { - throw new Error("issueSshWebSocketTicket not implemented in test"); - }, - onSshPasswordPrompt: () => () => undefined, - resolveSshPasswordPrompt: async () => undefined, - getServerExposureState: async () => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - setServerExposureMode: async () => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, - }), - setTailscaleServeEnabled: async (input) => ({ - mode: "local-only", - endpointUrl: null, - advertisedHost: null, - tailscaleServeEnabled: input.enabled, - tailscaleServePort: input.port ?? 443, - }), - getAdvertisedEndpoints: async () => [], - pickFolder: async () => null, - confirm: async () => true, - setTheme: async () => undefined, - showContextMenu: async () => null, - openExternal: async () => true, - createCloudAuthRequest: async () => "t3code-dev://auth/callback?t3_state=test", - getCloudAuthToken: async () => null, - setCloudAuthToken: async () => true, - clearCloudAuthToken: async () => undefined, - fetchCloudAuth: async () => ({ - ok: true, - status: 200, - statusText: "OK", - headers: {}, - body: "", - }), - onCloudAuthCallback: () => () => undefined, - onMenuAction: () => () => undefined, - getUpdateState: async () => { - throw new Error("getUpdateState not implemented in test"); - }, - setUpdateChannel: async () => { - throw new Error("setUpdateChannel not implemented in test"); - }, - checkForUpdate: async () => { - throw new Error("checkForUpdate not implemented in test"); - }, - downloadUpdate: async () => { - throw new Error("downloadUpdate not implemented in test"); - }, - installUpdate: async () => { - throw new Error("installUpdate not implemented in test"); - }, - onUpdateState: () => () => undefined, - ...overrides, - }; +function testWindow(): Window & typeof globalThis { + return globalThis.window ?? (globalThis as unknown as Window & typeof globalThis); } -const defaultProviders: ReadonlyArray<ServerProvider> = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }, -]; - -const baseEnvironment = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin" as const, - arch: "arm64" as const, - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const baseServerConfig: ServerConfig = { - environment: baseEnvironment, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/tmp/workspace", - keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", - keybindings: [], - issues: [], - providers: defaultProviders, - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/tmp/workspace/.config/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, -}; - -const baseGitStatus: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/streamed", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - showContextMenuFallbackMock.mockReset(); - terminalAttachListeners.clear(); - terminalMetadataListeners.clear(); - shellStreamListeners.clear(); - gitStatusListeners.clear(); - const testWindow = getWindowForTest(); - Reflect.deleteProperty(testWindow, "desktopBridge"); - Object.defineProperty(testWindow, "localStorage", { + if (globalThis.window === undefined) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: globalThis, + }); + } + Reflect.deleteProperty(testWindow(), "desktopBridge"); + Reflect.deleteProperty(testWindow(), "nativeApi"); + Object.defineProperty(testWindow(), "localStorage", { configurable: true, value: createLocalStorageStub(), }); @@ -352,411 +57,92 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("wsApi", () => { - it("forwards server config fetches directly to the RPC client", async () => { - rpcClientMock.server.getConfig.mockResolvedValue(baseServerConfig); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.getConfig()).resolves.toEqual(baseServerConfig); - expect(rpcClientMock.server.getConfig).toHaveBeenCalledWith(); - expect(rpcClientMock.server.subscribeConfig).not.toHaveBeenCalled(); - expect(rpcClientMock.server.subscribeLifecycle).not.toHaveBeenCalled(); - }); - - it("forwards terminal attach, metadata, and shell stream events", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onTerminalAttachEvent = vi.fn(); - const onTerminalMetadataEvent = vi.fn(); - const onShellEvent = vi.fn(); - - api.terminal.attach({ threadId: "thread-1", terminalId: "terminal-1" }, onTerminalAttachEvent); - api.terminal.onMetadata(onTerminalMetadataEvent); - api.orchestration.subscribeShell(onShellEvent); - - const terminalAttachEvent = { - threadId: "thread-1", - terminalId: "terminal-1", - type: "output", - data: "hello", - } satisfies TerminalAttachStreamEvent; - emitEvent(terminalAttachListeners, terminalAttachEvent); - - const terminalMetadataEvent = { - type: "upsert", - terminal: { - threadId: "thread-1", - terminalId: "terminal-1", - cwd: "/tmp/workspace", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - hasRunningSubprocess: true, - label: "terminal-1", - updatedAt: "2026-02-24T00:00:00.000Z", - }, - } satisfies TerminalMetadataStreamEvent; - emitEvent(terminalMetadataListeners, terminalMetadataEvent); - - const shellEvent = { - kind: "project-upserted" as const, - sequence: 1, - project: { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/workspace", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - scripts: [], - createdAt: "2026-02-24T00:00:00.000Z", - updatedAt: "2026-02-24T00:00:00.000Z", - }, - } satisfies OrchestrationShellStreamItem; - emitEvent(shellStreamListeners, shellEvent); - - expect(onTerminalAttachEvent).toHaveBeenCalledWith(terminalAttachEvent); - expect(onTerminalMetadataEvent).toHaveBeenCalledWith(terminalMetadataEvent); - expect(onShellEvent).toHaveBeenCalledWith(shellEvent); - }); - - it("forwards git status stream events", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onStatus = vi.fn(); - - api.vcs.onStatus({ cwd: "/repo" }, onStatus); - - const gitStatus = baseGitStatus; - emitEvent(gitStatusListeners, gitStatus); - - expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); - expect(onStatus).toHaveBeenCalledWith(gitStatus); - }); - - it("forwards git status refreshes directly to the RPC client", async () => { - rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - - await api.vcs.refreshStatus({ cwd: "/repo" }); - - expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - }); - - it("forwards shell stream subscription options to the RPC client", async () => { - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const onShellEvent = vi.fn(); - const onResubscribe = vi.fn(); - - api.orchestration.subscribeShell(onShellEvent, { onResubscribe }); - - expect(rpcClientMock.orchestration.subscribeShell).toHaveBeenCalledWith(onShellEvent, { - onResubscribe, - }); - }); - - it("sends orchestration dispatch commands as the direct RPC payload", async () => { - rpcClientMock.orchestration.dispatchCommand.mockResolvedValue({ sequence: 1 }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - const command = { - type: "project.create", - commandId: CommandId.make("cmd-1"), - projectId: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt: "2026-02-24T00:00:00.000Z", - } as const; - await api.orchestration.dispatchCommand(command); - - expect(rpcClientMock.orchestration.dispatchCommand).toHaveBeenCalledWith(command); - }); - - it("forwards workspace file writes to the project RPC", async () => { - rpcClientMock.projects.writeFile.mockResolvedValue({ relativePath: "plan.md" }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.projects.writeFile({ - cwd: "/tmp/project", - relativePath: "plan.md", - contents: "# Plan\n", - }); - - expect(rpcClientMock.projects.writeFile).toHaveBeenCalledWith({ - cwd: "/tmp/project", - relativePath: "plan.md", - contents: "# Plan\n", - }); - }); - - it("forwards filesystem browse requests to the RPC client", async () => { - rpcClientMock.filesystem.browse.mockResolvedValue({ - parentPath: "/tmp/project/", - entries: [], - }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.filesystem.browse({ - partialPath: "/tmp/project/", - cwd: "/tmp/project", - }); - - expect(rpcClientMock.filesystem.browse).toHaveBeenCalledWith({ - partialPath: "/tmp/project/", - cwd: "/tmp/project", - }); - }); - - it("forwards full-thread diff requests to the orchestration RPC", async () => { - rpcClientMock.orchestration.getFullThreadDiff.mockResolvedValue({ diff: "patch" }); - const { createEnvironmentApi } = await import("./environmentApi"); - - const api = createEnvironmentApi(rpcClientMock as never); - await api.orchestration.getFullThreadDiff({ - threadId: ThreadId.make("thread-1"), - toTurnCount: 1, - }); - - expect(rpcClientMock.orchestration.getFullThreadDiff).toHaveBeenCalledWith({ - threadId: "thread-1", - toTurnCount: 1, - }); - }); - - it("forwards provider refreshes directly to the RPC client", async () => { - const nextProviders: ReadonlyArray<ServerProvider> = [ - { - ...defaultProviders[0]!, - checkedAt: "2026-01-03T00:00:00.000Z", - }, - ]; - rpcClientMock.server.refreshProviders.mockResolvedValue({ providers: nextProviders }); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); - expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); - }); - - it("forwards provider updates directly to the RPC client", async () => { - const nextProviders: ReadonlyArray<ServerProvider> = [ - { - ...defaultProviders[0]!, - updateState: { - status: "succeeded", - startedAt: "2026-01-03T00:00:00.000Z", - finishedAt: "2026-01-03T00:00:01.000Z", - message: "Provider updated.", - output: null, - }, - }, - ]; - rpcClientMock.server.updateProvider.mockResolvedValue({ providers: nextProviders }); - const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); - - await expect( - api.server.updateProvider({ provider: ProviderDriverKind.make("codex") }), - ).resolves.toEqual({ - providers: nextProviders, - }); - expect(rpcClientMock.server.updateProvider).toHaveBeenCalledWith({ - provider: ProviderDriverKind.make("codex"), - }); - }); - - it("forwards server settings updates directly to the RPC client", async () => { - const nextSettings = { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }; - rpcClientMock.server.updateSettings.mockResolvedValue(nextSettings); +describe("LocalApi", () => { + it("keeps backend operations unavailable in the browser facade", async () => { const { createLocalApi } = await import("./localApi"); + const api = createLocalApi(); - const api = createLocalApi(rpcClientMock as never); - - await expect(api.server.updateSettings({ enableAssistantStreaming: true })).resolves.toEqual( - nextSettings, + await expect(api.server.getConfig()).rejects.toThrow( + "Local backend API is unavailable before a backend is paired.", ); - expect(rpcClientMock.server.updateSettings).toHaveBeenCalledWith({ - enableAssistantStreaming: true, - }); - }); - - it("forwards context menu metadata to the desktop bridge", async () => { - const showContextMenu = vi.fn().mockResolvedValue("delete"); - getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); - - const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - const items = [{ id: "delete", label: "Delete" }] as const; - - await expect(api.contextMenu.show(items)).resolves.toBe("delete"); - expect(showContextMenu).toHaveBeenCalledWith(items, undefined); - }); - - it("forwards folder picker options to the desktop bridge", async () => { - const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); - getWindowForTest().desktopBridge = makeDesktopBridge({ pickFolder }); - - const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - - await expect(api.dialogs.pickFolder({ initialPath: "/tmp/workspace" })).resolves.toBe( - "/tmp/project", + await expect(api.shell.openInEditor("/tmp", "cursor")).rejects.toThrow( + "Local backend API is unavailable before a backend is paired.", ); - expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp/workspace" }); }); - it("falls back to the browser context menu helper when the desktop bridge is missing", async () => { + it("uses the browser context-menu fallback without a desktop bridge", async () => { showContextMenuFallbackMock.mockResolvedValue("rename"); const { createLocalApi } = await import("./localApi"); - - const api = createLocalApi(rpcClientMock as never); const items = [{ id: "rename", label: "Rename" }] as const; - await expect(api.contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); + await expect(createLocalApi().contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); }); - it("reads and writes persistence through the desktop bridge when available", async () => { - const clientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path" as const, - sidebarProjectGroupingOverrides: { - "environment-local:/tmp/project": "separate" as const, - }, - sidebarProjectSortOrder: "manual" as const, - sidebarThreadSortOrder: "created_at" as const, - sidebarThreadPreviewCount: 6, - timestampFormat: "24-hour" as const, - }; - const getClientSettings = vi.fn().mockResolvedValue({ - ...clientSettings, - }); - const setClientSettings = vi.fn().mockResolvedValue(undefined); - const getSavedEnvironmentRegistry = vi.fn().mockResolvedValue([]); - const setSavedEnvironmentRegistry = vi.fn().mockResolvedValue(undefined); - const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("bearer-token"); + it("delegates host capabilities and persistence to the desktop bridge", async () => { + const showContextMenu = vi.fn().mockResolvedValue("delete"); + const pickFolder = vi.fn().mockResolvedValue("/tmp/project"); + const getSavedEnvironmentSecret = vi.fn().mockResolvedValue("secret"); const setSavedEnvironmentSecret = vi.fn().mockResolvedValue(true); const removeSavedEnvironmentSecret = vi.fn().mockResolvedValue(undefined); - getWindowForTest().desktopBridge = makeDesktopBridge({ - getClientSettings, - setClientSettings, - getSavedEnvironmentRegistry, - setSavedEnvironmentRegistry, + testWindow().desktopBridge = { + showContextMenu, + pickFolder, getSavedEnvironmentSecret, setSavedEnvironmentSecret, removeSavedEnvironmentSecret, - }); + } as unknown as DesktopBridge; const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); + const api = createLocalApi(); + const environmentId = EnvironmentId.make("environment-1"); + const items = [{ id: "delete", label: "Delete" }] as const; - await api.persistence.getClientSettings(); - await api.persistence.setClientSettings(clientSettings); - await api.persistence.getSavedEnvironmentRegistry(); - await api.persistence.setSavedEnvironmentRegistry([]); - await api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")); - await api.persistence.setSavedEnvironmentSecret( - EnvironmentId.make("environment-local"), - "bearer-token", + await expect(api.contextMenu.show(items)).resolves.toBe("delete"); + await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project"); + await expect(api.persistence.getSavedEnvironmentSecret(environmentId)).resolves.toBe("secret"); + await expect(api.persistence.setSavedEnvironmentSecret(environmentId, "next")).resolves.toBe( + true, ); - await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); + await api.persistence.removeSavedEnvironmentSecret(environmentId); - expect(getClientSettings).toHaveBeenCalledWith(); - expect(setClientSettings).toHaveBeenCalledWith(clientSettings); - expect(getSavedEnvironmentRegistry).toHaveBeenCalledWith(); - expect(setSavedEnvironmentRegistry).toHaveBeenCalledWith([]); - expect(getSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); - expect(setSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local", "bearer-token"); - expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith("environment-local"); + expect(showContextMenu).toHaveBeenCalledWith(items, undefined); + expect(pickFolder).toHaveBeenCalledWith({ initialPath: "/tmp" }); + expect(getSavedEnvironmentSecret).toHaveBeenCalledWith(environmentId); + expect(setSavedEnvironmentSecret).toHaveBeenCalledWith(environmentId, "next"); + expect(removeSavedEnvironmentSecret).toHaveBeenCalledWith(environmentId); }); - it("falls back to browser storage for persistence when the desktop bridge is missing", async () => { + it("persists connection records and secrets in browser storage", async () => { const { createLocalApi } = await import("./localApi"); - const api = createLocalApi(rpcClientMock as never); - const clientSettings = { - autoOpenPlanSidebar: false, - confirmThreadArchive: true, - confirmThreadDelete: false, - dismissedProviderUpdateNotificationKeys: [], - diffIgnoreWhitespace: true, - diffWordWrap: true, - favorites: [], - providerModelPreferences: {}, - sidebarProjectGroupingMode: "repository_path" as const, - sidebarProjectGroupingOverrides: { - "environment-local:/tmp/project": "separate" as const, - }, - sidebarProjectSortOrder: "manual" as const, - sidebarThreadSortOrder: "created_at" as const, - sidebarThreadPreviewCount: 6, - timestampFormat: "24-hour" as const, - }; - - await api.persistence.setClientSettings(clientSettings); - await api.persistence.setSavedEnvironmentRegistry([ + const api = createLocalApi(); + const environmentId = EnvironmentId.make("environment-1"); + const records = [ { - environmentId: EnvironmentId.make("environment-local"), - label: "Primary", - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - createdAt: "2026-04-09T00:00:00.000Z", + environmentId, + label: "Remote", + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test", + createdAt: "2026-06-06T00:00:00.000Z", lastConnectedAt: null, }, - ]); - await api.persistence.setSavedEnvironmentSecret( - EnvironmentId.make("environment-local"), - "bearer-token", - ); + ]; - await expect(api.persistence.getClientSettings()).resolves.toEqual(clientSettings); - await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual([ - { - environmentId: EnvironmentId.make("environment-local"), - label: "Primary", - httpBaseUrl: "http://localhost:3000", - wsBaseUrl: "ws://localhost:3000", - createdAt: "2026-04-09T00:00:00.000Z", - lastConnectedAt: null, - }, - ]); - await expect( - api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), - ).resolves.toBe("bearer-token"); + await api.persistence.setSavedEnvironmentRegistry(records); + await api.persistence.setSavedEnvironmentSecret(environmentId, "secret"); + + await expect(api.persistence.getSavedEnvironmentRegistry()).resolves.toEqual(records); + await expect(api.persistence.getSavedEnvironmentSecret(environmentId)).resolves.toBe("secret"); + + await api.persistence.removeSavedEnvironmentSecret(environmentId); + await expect(api.persistence.getSavedEnvironmentSecret(environmentId)).resolves.toBeNull(); + }); - await api.persistence.removeSavedEnvironmentSecret(EnvironmentId.make("environment-local")); + it("prefers the native LocalApi when one is injected", async () => { + const nativeApi = { dialogs: {} }; + testWindow().nativeApi = nativeApi as never; + const { readLocalApi } = await import("./localApi"); - await expect( - api.persistence.getSavedEnvironmentSecret(EnvironmentId.make("environment-local")), - ).resolves.toBeNull(); + expect(readLocalApi()).toBe(nativeApi); }); }); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c5ee3f277ca..484dd7bb783 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,20 +1,6 @@ import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { resetVcsStatusStateForTests } from "./lib/vcsStatusState"; -import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; -import { resetServerStateForTests } from "./rpc/serverState"; -import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; -import { - resetSavedEnvironmentRegistryStoreForTests, - resetSavedEnvironmentRuntimeStoreForTests, -} from "./environments/runtime"; -import { - getPrimaryEnvironmentConnection, - resetEnvironmentServiceForTests, -} from "./environments/runtime"; -import { getPrimaryKnownEnvironment } from "./environments/primary"; import { showContextMenuFallback } from "./contextMenuFallback"; import { readBrowserClientSettings, @@ -32,7 +18,7 @@ function unavailableLocalBackendError(): Error { return new Error("Local backend API is unavailable before a backend is paired."); } -function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { +function createBrowserLocalApi(): LocalApi { return { dialogs: { pickFolder: async (options) => { @@ -47,10 +33,7 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { }, }, shell: { - openInEditor: (cwd, editor) => - rpcClient - ? rpcClient.shell.openInEditor({ cwd, editor }) - : Promise.reject(unavailableLocalBackendError()), + openInEditor: () => Promise.reject(unavailableLocalBackendError()), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); @@ -119,56 +102,24 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { }, }, server: { - getConfig: () => - rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), - refreshProviders: () => - rpcClient - ? rpcClient.server.refreshProviders() - : Promise.reject(unavailableLocalBackendError()), - updateProvider: (input) => - rpcClient - ? rpcClient.server.updateProvider(input) - : Promise.reject(unavailableLocalBackendError()), - upsertKeybinding: (input) => - rpcClient - ? rpcClient.server.upsertKeybinding(input) - : Promise.reject(unavailableLocalBackendError()), - removeKeybinding: (input) => - rpcClient - ? rpcClient.server.removeKeybinding(input) - : Promise.reject(unavailableLocalBackendError()), - getSettings: () => - rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), - updateSettings: (patch) => - rpcClient - ? rpcClient.server.updateSettings(patch) - : Promise.reject(unavailableLocalBackendError()), - discoverSourceControl: () => - rpcClient - ? rpcClient.server.discoverSourceControl() - : Promise.reject(unavailableLocalBackendError()), - getTraceDiagnostics: () => - rpcClient - ? rpcClient.server.getTraceDiagnostics() - : Promise.reject(unavailableLocalBackendError()), - getProcessDiagnostics: () => - rpcClient - ? rpcClient.server.getProcessDiagnostics() - : Promise.reject(unavailableLocalBackendError()), - getProcessResourceHistory: (input) => - rpcClient - ? rpcClient.server.getProcessResourceHistory(input) - : Promise.reject(unavailableLocalBackendError()), - signalProcess: (input) => - rpcClient - ? rpcClient.server.signalProcess(input) - : Promise.reject(unavailableLocalBackendError()), + getConfig: () => Promise.reject(unavailableLocalBackendError()), + refreshProviders: () => Promise.reject(unavailableLocalBackendError()), + updateProvider: () => Promise.reject(unavailableLocalBackendError()), + upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), + removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), + getSettings: () => Promise.reject(unavailableLocalBackendError()), + updateSettings: () => Promise.reject(unavailableLocalBackendError()), + discoverSourceControl: () => Promise.reject(unavailableLocalBackendError()), + getTraceDiagnostics: () => Promise.reject(unavailableLocalBackendError()), + getProcessDiagnostics: () => Promise.reject(unavailableLocalBackendError()), + getProcessResourceHistory: () => Promise.reject(unavailableLocalBackendError()), + signalProcess: () => Promise.reject(unavailableLocalBackendError()), }, }; } -export function createLocalApi(rpcClient: WsRpcClient): LocalApi { - return createBrowserLocalApi(rpcClient); +export function createLocalApi(): LocalApi { + return createBrowserLocalApi(); } export function readLocalApi(): LocalApi | undefined { @@ -180,10 +131,7 @@ export function readLocalApi(): LocalApi | undefined { return cachedApi; } - const primaryEnvironment = getPrimaryKnownEnvironment(); - cachedApi = primaryEnvironment - ? createLocalApi(getPrimaryEnvironmentConnection().client) - : createBrowserLocalApi(); + cachedApi = createBrowserLocalApi(); return cachedApi; } @@ -199,12 +147,5 @@ export async function __resetLocalApiForTests() { cachedApi = undefined; const { __resetClientSettingsPersistenceForTests } = await import("./hooks/useSettings"); __resetClientSettingsPersistenceForTests(); - await resetEnvironmentServiceForTests(); - resetVcsStatusStateForTests(); - resetSourceControlDiscoveryStateForTests(); resetRequestLatencyStateForTests(); - resetSavedEnvironmentRegistryStoreForTests(); - resetSavedEnvironmentRuntimeStoreForTests(); - resetServerStateForTests(); - resetWsConnectionStateForTests(); } diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 72415d57de0..d3e3f919048 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -1,8 +1,8 @@ -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { ScopedProjectRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; -import type { Project } from "./types"; export interface ProjectGroupingSettings { sidebarProjectGroupingMode: SidebarProjectGroupingMode; @@ -33,14 +33,14 @@ function uniqueNonEmptyValues(values: ReadonlyArray<string | null | undefined>): } function deriveRepositoryRelativeProjectPath( - project: Pick<Project, "cwd" | "repositoryIdentity">, + project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, ): string | null { const rootPath = project.repositoryIdentity?.rootPath?.trim(); if (!rootPath) { return null; } - const normalizedProjectPath = normalizeProjectPathForComparison(project.cwd); + const normalizedProjectPath = normalizeProjectPathForComparison(project.workspaceRoot); const normalizedRootPath = normalizeProjectPathForComparison(rootPath); if (normalizedProjectPath.length === 0 || normalizedRootPath.length === 0) { return null; @@ -63,12 +63,14 @@ export function derivePhysicalProjectKeyFromPath(environmentId: string, cwd: str return `${environmentId}:${normalizeProjectPathForComparison(cwd)}`; } -export function derivePhysicalProjectKey(project: Pick<Project, "environmentId" | "cwd">): string { - return derivePhysicalProjectKeyFromPath(project.environmentId, project.cwd); +export function derivePhysicalProjectKey( + project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, +): string { + return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); } export function deriveProjectGroupingOverrideKey( - project: Pick<Project, "environmentId" | "cwd">, + project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, ): string { return derivePhysicalProjectKey(project); } @@ -76,12 +78,14 @@ export function deriveProjectGroupingOverrideKey( // Key under which a project's manual sort order (projectOrder) is stored. // Must stay aligned with the writer side in `uiStateStore.syncProjects` and // the drag handlers in `Sidebar` so readers and writers agree. -export function getProjectOrderKey(project: Pick<Project, "environmentId" | "cwd">): string { +export function getProjectOrderKey( + project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, +): string { return derivePhysicalProjectKey(project); } export function resolveProjectGroupingMode( - project: Pick<Project, "environmentId" | "cwd">, + project: Pick<EnvironmentProject, "environmentId" | "workspaceRoot">, settings: ProjectGroupingSettings, ): SidebarProjectGroupingMode { return ( @@ -91,7 +95,7 @@ export function resolveProjectGroupingMode( } function deriveRepositoryScopedKey( - project: Pick<Project, "cwd" | "repositoryIdentity">, + project: Pick<EnvironmentProject, "workspaceRoot" | "repositoryIdentity">, groupingMode: SidebarProjectGroupingMode, ): string | null { const canonicalKey = project.repositoryIdentity?.canonicalKey; @@ -114,7 +118,10 @@ function deriveRepositoryScopedKey( } export function deriveLogicalProjectKey( - project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity">, + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, options?: { groupingMode?: SidebarProjectGroupingMode; }, @@ -132,7 +139,10 @@ export function deriveLogicalProjectKey( } export function deriveLogicalProjectKeyFromSettings( - project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity">, + project: Pick< + EnvironmentProject, + "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" + >, settings: ProjectGroupingSettings, ): string { return deriveLogicalProjectKey(project, { @@ -142,7 +152,10 @@ export function deriveLogicalProjectKeyFromSettings( export function deriveLogicalProjectKeyFromRef( projectRef: ScopedProjectRef, - project: Pick<Project, "environmentId" | "id" | "cwd" | "repositoryIdentity"> | null | undefined, + project: + | Pick<EnvironmentProject, "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity"> + | null + | undefined, options?: { groupingMode?: SidebarProjectGroupingMode; }, @@ -151,8 +164,8 @@ export function deriveLogicalProjectKeyFromRef( } export function deriveProjectGroupLabel(input: { - representative: Pick<Project, "name" | "repositoryIdentity">; - members: ReadonlyArray<Pick<Project, "name" | "repositoryIdentity">>; + representative: Pick<EnvironmentProject, "title" | "repositoryIdentity">; + members: ReadonlyArray<Pick<EnvironmentProject, "title" | "repositoryIdentity">>; }): string { const sharedDisplayNames = uniqueNonEmptyValues( input.members.map((member) => member.repositoryIdentity?.displayName), @@ -168,5 +181,5 @@ export function deriveProjectGroupLabel(input: { return sharedRepositoryNames[0]!; } - return input.representative.name; + return input.representative.title; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index e92cad9aa3d..8d56b687738 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -17,6 +17,7 @@ import { hasCloudPublicConfig } from "./cloud/publicConfig"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; +import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); @@ -31,7 +32,12 @@ document.title = APP_DISPLAY_NAME; const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -const app = <RouterProvider router={router} />; +const app = ( + <> + <RouterProvider router={router} /> + <ElectronBrowserHost /> + </> +); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( <React.StrictMode> diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts new file mode 100644 index 00000000000..c46f9e816dc --- /dev/null +++ b/apps/web/src/portDiscoveryState.ts @@ -0,0 +1,77 @@ +import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useMemo } from "react"; +import { create } from "zustand"; + +import { previewEnvironment } from "./state/preview"; +import { useEnvironmentQuery } from "./state/query"; + +const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]); + +interface PortDiscoveryState { + readonly byEnvironment: Record<string, ReadonlyArray<DiscoveredLocalServer>>; + setPorts: (environmentId: EnvironmentId, ports: ReadonlyArray<DiscoveredLocalServer>) => void; + clearEnvironment: (environmentId: EnvironmentId) => void; + reset: () => void; +} + +export const usePortDiscoveryStore = create<PortDiscoveryState>((set) => ({ + byEnvironment: {}, + setPorts: (environmentId, ports) => + set((state) => ({ + byEnvironment: { + ...state.byEnvironment, + [environmentId]: ports, + }, + })), + clearEnvironment: (environmentId) => + set((state) => { + if (!(environmentId in state.byEnvironment)) return state; + const { [environmentId]: _removed, ...byEnvironment } = state.byEnvironment; + return { byEnvironment }; + }), + reset: () => set({ byEnvironment: {} }), +})); + +export function useDiscoveredPorts( + environmentId: EnvironmentId | null, +): ReadonlyArray<DiscoveredLocalServer> { + const query = useEnvironmentQuery( + environmentId === null + ? null + : previewEnvironment.discoveredServers({ environmentId, input: {} }), + ); + return query.data?.servers ?? EMPTY_PORTS; +} + +export function useThreadDiscoveredPorts(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray<DiscoveredLocalServer> { + const ports = useDiscoveredPorts(input.environmentId); + return useMemo( + () => + input.threadId + ? ports.filter((port) => port.terminal?.threadId === input.threadId) + : EMPTY_PORTS, + [input.threadId, ports], + ); +} + +export function useTerminalDiscoveredPorts(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly terminalId: string | null; +}): ReadonlyArray<DiscoveredLocalServer> { + const ports = useDiscoveredPorts(input.environmentId); + return useMemo( + () => + input.threadId && input.terminalId + ? ports.filter( + (port) => + port.terminal?.threadId === input.threadId && + port.terminal.terminalId === input.terminalId, + ) + : EMPTY_PORTS, + [input.terminalId, input.threadId, ports], + ); +} diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts new file mode 100644 index 00000000000..d2ef801e526 --- /dev/null +++ b/apps/web/src/previewStateStore.test.ts @@ -0,0 +1,310 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, type PreviewSessionSnapshot, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { __testing, selectThreadPreviewState, usePreviewStateStore } from "./previewStateStore"; + +const environmentId = "env-1" as EnvironmentId; +const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); + +const makeSnapshot = (overrides: Partial<PreviewSessionSnapshot> = {}): PreviewSessionSnapshot => ({ + threadId: "thread-1", + tabId: "tab_a", + navStatus: { _tag: "Loading", url: "http://localhost:5173/", title: "" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +beforeEach(() => { + usePreviewStateStore.setState({ byThreadKey: {} }); +}); + +describe("previewStateStore (single-tab)", () => { + it("opened event seeds the snapshot and remembers the URL", () => { + const snapshot = makeSnapshot(); + usePreviewStateStore.getState().applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(snapshot.tabId); + expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); + }); + + it("a second `opened` for a different tab replaces the rendered snapshot", () => { + const a = makeSnapshot({ tabId: "tab_a" }); + const b = makeSnapshot({ tabId: "tab_b" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: a.tabId, + createdAt: a.updatedAt, + snapshot: a, + }); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: b.tabId, + createdAt: b.updatedAt, + snapshot: b, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(b.tabId); + }); + + it("navigated event updates the snapshot URL", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "navigated", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + snapshot: { + ...snapshot, + navStatus: { _tag: "Success", url: "http://localhost:5173/about", title: "About" }, + }, + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("Success"); + if (state.snapshot?.navStatus._tag === "Success") { + expect(state.snapshot.navStatus.url).toBe("http://localhost:5173/about"); + } + }); + + it("failed event flips the snapshot to LoadFailed when tabId matches", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "failed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + url: "http://localhost:5173/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("LoadFailed"); + }); + + it("failed event for a non-active tab is ignored", () => { + const snapshot = makeSnapshot({ tabId: "tab_a" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "failed", + threadId: "thread-1", + tabId: "tab_b", + createdAt: "2026-01-01T00:00:01.000Z", + url: "http://localhost:9999/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus._tag).toBe("Loading"); + }); + + it("closed event clears snapshot but retains recently-seen URLs", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot).toBeNull(); + expect(state.recentlySeenUrls).toContain("http://localhost:5173/"); + }); + + it("optimistically removes a session before the server close event arrives", () => { + const first = makeSnapshot({ tabId: "tab_a" }); + const second = makeSnapshot({ + tabId: "tab_b", + updatedAt: "2026-01-01T00:00:01.000Z", + }); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, first); + store.applyServerSnapshot(ref, second); + + store.removeSession(ref, second.tabId); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(Object.keys(state.sessions)).toEqual([first.tabId]); + expect(state.activeTabId).toBe(first.tabId); + expect(state.snapshot?.tabId).toBe(first.tabId); + }); + + it("treats a late server close event after optimistic removal as a no-op", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.removeSession(ref, snapshot.tabId); + + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: "2026-01-01T00:00:01.000Z", + }); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.sessions).toEqual({}); + expect(state.snapshot).toBeNull(); + }); + + it("closed event for a different tab is a no-op", () => { + const snapshot = makeSnapshot({ tabId: "tab_a" }); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyServerEvent(ref, { + type: "closed", + threadId: "thread-1", + tabId: "tab_b", + createdAt: "2026-01-01T00:00:01.000Z", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.tabId).toBe(snapshot.tabId); + }); + + it("desktopOverlay updates independently of snapshot", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerEvent(ref, { + type: "opened", + threadId: "thread-1", + tabId: snapshot.tabId, + createdAt: snapshot.updatedAt, + snapshot, + }); + store.applyDesktopState(ref, snapshot.tabId, { + canGoBack: true, + canGoForward: false, + loading: false, + zoomFactor: 1, + controller: "none", + }); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.desktopOverlay?.canGoBack).toBe(true); + expect(state.snapshot?.canGoBack).toBe(false); + }); + + it("retains multiple tabs and switches active desktop state", () => { + const first = makeSnapshot(); + const second = { ...makeSnapshot(), tabId: "tab_2", updatedAt: "2026-01-02T00:00:00.000Z" }; + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, first); + store.applyServerSnapshot(ref, second); + store.applyDesktopState(ref, first.tabId, { + canGoBack: true, + canGoForward: false, + loading: false, + zoomFactor: 1, + controller: "none", + }); + store.setActiveTab(ref, first.tabId); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(Object.keys(state.sessions)).toEqual([first.tabId, second.tabId]); + expect(state.snapshot?.tabId).toBe(first.tabId); + expect(state.desktopOverlay?.canGoBack).toBe(true); + }); + + it("applyServerSnapshot null clears snapshot for a thread that had one", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.applyServerSnapshot(ref, null); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot).toBeNull(); + }); + + it("does not replace a streamed snapshot with older SWR data", () => { + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot( + ref, + makeSnapshot({ + navStatus: { _tag: "Success", url: "http://localhost:5173/new", title: "New" }, + updatedAt: "2026-01-01T00:00:02.000Z", + }), + ); + store.applyServerSnapshot( + ref, + makeSnapshot({ + navStatus: { _tag: "Success", url: "http://localhost:5173/old", title: "Old" }, + updatedAt: "2026-01-01T00:00:01.000Z", + }), + ); + + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.snapshot?.navStatus).toEqual({ + _tag: "Success", + url: "http://localhost:5173/new", + title: "New", + }); + }); + + it("rememberUrl dedupes and caps at limit", () => { + const store = usePreviewStateStore.getState(); + for (let i = 0; i < __testing.RECENT_URL_LIMIT + 5; i += 1) { + store.rememberUrl(ref, `http://localhost:${5000 + i}/`); + } + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state.recentlySeenUrls.length).toBeLessThanOrEqual(__testing.RECENT_URL_LIMIT); + expect(state.recentlySeenUrls[0]).toBe( + `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, + ); + }); + + it("removeThread strips the entry", () => { + const snapshot = makeSnapshot(); + const store = usePreviewStateStore.getState(); + store.applyServerSnapshot(ref, snapshot); + store.removeThread(ref); + const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, ref); + expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); + }); +}); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts new file mode 100644 index 00000000000..d0d615354b4 --- /dev/null +++ b/apps/web/src/previewStateStore.ts @@ -0,0 +1,296 @@ +/** + * Per-thread preview UI state. + * + * Single-tab model: one snapshot per thread, mirrored two ways: + * - `snapshot` is the server-authoritative URL/title/load-status, replayed + * on WS reconnect so the panel survives backend restarts. + * - `desktopOverlay` is low-latency state from the local <webview> + * (canGoBack/canGoForward/visible/zoom/loading), used by the chrome row's + * button enablement. + * + * The schema-level `tabId` exists because the server still keys sessions by + * `(threadId, tabId)`; the client just always tracks one and ignores the rest. + */ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + type PreviewEvent, + type PreviewSessionSnapshot, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { create } from "zustand"; + +import { PREVIEW_RECENT_URL_LIMIT } from "./components/preview/previewConstants"; + +export interface DesktopPreviewOverlay { + canGoBack: boolean; + canGoForward: boolean; + loading: boolean; + zoomFactor: number; + controller: "human" | "agent" | "none"; +} + +export interface ThreadPreviewState { + snapshot: PreviewSessionSnapshot | null; + sessions: Record<string, PreviewSessionSnapshot>; + activeTabId: string | null; + /** Bridge state takes precedence over `snapshot` for nav button enablement. */ + desktopOverlay: DesktopPreviewOverlay | null; + desktopByTabId: Record<string, DesktopPreviewOverlay>; + /** Recently-visited URLs surfaced in the empty state. */ + recentlySeenUrls: string[]; +} + +const EMPTY_THREAD_PREVIEW_STATE: ThreadPreviewState = Object.freeze({ + snapshot: null, + sessions: {}, + activeTabId: null, + desktopOverlay: null, + desktopByTabId: {}, + recentlySeenUrls: [] as string[], +}); + +const revisionByThreadKey = new Map<string, number>(); + +const bumpPreviewStateRevision = (threadKey: string): void => { + revisionByThreadKey.set(threadKey, (revisionByThreadKey.get(threadKey) ?? 0) + 1); +}; + +export function readPreviewStateRevision(ref: ScopedThreadRef): number { + return revisionByThreadKey.get(scopedThreadKey(ref)) ?? 0; +} + +export interface PreviewStateStoreState { + byThreadKey: Record<string, ThreadPreviewState>; + applyServerEvent: (ref: ScopedThreadRef, event: PreviewEvent) => void; + applyServerSnapshot: (ref: ScopedThreadRef, snapshot: PreviewSessionSnapshot | null) => void; + applyDesktopState: ( + ref: ScopedThreadRef, + tabId: string, + overlay: DesktopPreviewOverlay | null, + ) => void; + removeSession: (ref: ScopedThreadRef, tabId: string) => void; + setActiveTab: (ref: ScopedThreadRef, tabId: string) => void; + rememberUrl: (ref: ScopedThreadRef, url: string) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +const ensureState = ( + byThreadKey: Record<string, ThreadPreviewState>, + threadKey: string, +): ThreadPreviewState => byThreadKey[threadKey] ?? EMPTY_THREAD_PREVIEW_STATE; + +const updateThread = ( + state: PreviewStateStoreState, + threadKey: string, + updater: (current: ThreadPreviewState) => ThreadPreviewState, +): PreviewStateStoreState["byThreadKey"] => { + const current = ensureState(state.byThreadKey, threadKey); + const next = updater(current); + if (next === current) return state.byThreadKey; + return { ...state.byThreadKey, [threadKey]: next }; +}; + +const removeThreadKey = ( + byThreadKey: Record<string, ThreadPreviewState>, + threadKey: string, +): Record<string, ThreadPreviewState> => { + if (!(threadKey in byThreadKey)) return byThreadKey; + const { [threadKey]: _removed, ...rest } = byThreadKey; + return rest; +}; + +const dedupeRecentUrls = (existing: string[], url: string): string[] => { + const next = [url, ...existing.filter((entry) => entry !== url)]; + return next.slice(0, PREVIEW_RECENT_URL_LIMIT); +}; + +const removeSession = (current: ThreadPreviewState, tabId: string): ThreadPreviewState => { + if (!current.sessions[tabId]) return current; + const { [tabId]: _closed, ...sessions } = current.sessions; + const { [tabId]: _desktop, ...desktopByTabId } = current.desktopByTabId; + const nextSnapshot = + Object.values(sessions) + .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)) + .at(-1) ?? null; + const activeTabId = + current.activeTabId === tabId ? (nextSnapshot?.tabId ?? null) : current.activeTabId; + const snapshot = activeTabId ? (sessions[activeTabId] ?? nextSnapshot) : nextSnapshot; + return { + ...current, + sessions, + desktopByTabId, + activeTabId: snapshot?.tabId ?? null, + snapshot, + desktopOverlay: snapshot ? (desktopByTabId[snapshot.tabId] ?? null) : null, + }; +}; + +export const usePreviewStateStore = create<PreviewStateStoreState>()((set) => ({ + byThreadKey: {}, + applyServerEvent: (ref, event) => + set((state) => { + const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); + let nextByThread = state.byThreadKey; + switch (event.type) { + case "opened": + case "navigated": + nextByThread = updateThread(state, threadKey, (current) => { + const snapshot = event.snapshot; + const recentlySeenUrls = + snapshot.navStatus._tag === "Idle" + ? current.recentlySeenUrls + : dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url); + const sessions = { ...current.sessions, [snapshot.tabId]: snapshot }; + const activeTabId = event.type === "opened" ? snapshot.tabId : current.activeTabId; + const activeSnapshot = sessions[activeTabId ?? snapshot.tabId] ?? snapshot; + return { + ...current, + sessions, + activeTabId: activeTabId ?? snapshot.tabId, + snapshot: activeSnapshot, + desktopOverlay: current.desktopByTabId[activeSnapshot.tabId] ?? null, + recentlySeenUrls, + }; + }); + break; + case "failed": + nextByThread = updateThread(state, threadKey, (current) => { + const existing = current.sessions[event.tabId]; + if (!existing) return current; + const failedSnapshot = { + ...existing, + navStatus: { + _tag: "LoadFailed" as const, + url: event.url, + title: event.title, + code: event.code, + description: event.description, + }, + updatedAt: event.createdAt, + }; + const sessions = { ...current.sessions, [event.tabId]: failedSnapshot }; + return { + ...current, + sessions, + snapshot: current.activeTabId === event.tabId ? failedSnapshot : current.snapshot, + }; + }); + break; + case "closed": + nextByThread = updateThread(state, threadKey, (current) => + removeSession(current, event.tabId), + ); + break; + } + return { byThreadKey: nextByThread }; + }), + applyServerSnapshot: (ref, snapshot) => + set((state) => { + const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); + const nextByThread = updateThread(state, threadKey, (current) => { + if (!snapshot && current.snapshot === null) return current; + if (!snapshot) { + return { + ...current, + snapshot: null, + sessions: {}, + activeTabId: null, + desktopOverlay: null, + desktopByTabId: {}, + }; + } + const existing = current.sessions[snapshot.tabId]; + if (existing && existing.updatedAt > snapshot.updatedAt) { + return current; + } + const recentlySeenUrls = + snapshot && snapshot.navStatus._tag !== "Idle" + ? dedupeRecentUrls(current.recentlySeenUrls, snapshot.navStatus.url) + : current.recentlySeenUrls; + return { + ...current, + snapshot, + sessions: { ...current.sessions, [snapshot.tabId]: snapshot }, + activeTabId: snapshot.tabId, + desktopOverlay: current.desktopByTabId[snapshot.tabId] ?? null, + recentlySeenUrls, + }; + }); + return { byThreadKey: nextByThread }; + }), + applyDesktopState: (ref, tabId, overlay) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => { + const desktopByTabId = { ...current.desktopByTabId }; + if (overlay) desktopByTabId[tabId] = overlay; + else delete desktopByTabId[tabId]; + return { + ...current, + desktopByTabId, + desktopOverlay: current.activeTabId === tabId ? overlay : current.desktopOverlay, + }; + }); + return { byThreadKey: nextByThread }; + }), + removeSession: (ref, tabId) => + set((state) => { + const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); + return { + byThreadKey: updateThread(state, threadKey, (current) => removeSession(current, tabId)), + }; + }), + setActiveTab: (ref, tabId) => + set((state) => { + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => { + const snapshot = current.sessions[tabId]; + if (!snapshot || current.activeTabId === tabId) return current; + return { + ...current, + activeTabId: tabId, + snapshot, + desktopOverlay: current.desktopByTabId[tabId] ?? null, + }; + }); + return { byThreadKey: nextByThread }; + }), + rememberUrl: (ref, url) => + set((state) => { + if (url.trim().length === 0) return state; + const threadKey = scopedThreadKey(ref); + const nextByThread = updateThread(state, threadKey, (current) => ({ + ...current, + recentlySeenUrls: dedupeRecentUrls(current.recentlySeenUrls, url), + })); + return { byThreadKey: nextByThread }; + }), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + bumpPreviewStateRevision(threadKey); + if (!(threadKey in state.byThreadKey)) return state; + return { byThreadKey: removeThreadKey(state.byThreadKey, threadKey) }; + }), +})); + +export function selectThreadPreviewState( + byThreadKey: Record<string, ThreadPreviewState>, + ref: ScopedThreadRef | null | undefined, +): ThreadPreviewState { + if (!ref) return EMPTY_THREAD_PREVIEW_STATE; + return ensureState(byThreadKey, scopedThreadKey(ref)); +} + +export function isPreviewSupportedInRuntime(): boolean { + if (typeof window === "undefined") return false; + return Boolean(window.desktopBridge?.preview); +} + +export const __testing = { + EMPTY_THREAD_PREVIEW_STATE, + RECENT_URL_LIMIT: PREVIEW_RECENT_URL_LIMIT, +}; diff --git a/apps/web/src/projectScripts.ts b/apps/web/src/projectScripts.ts index 1c02b4e8104..f0aeead1411 100644 --- a/apps/web/src/projectScripts.ts +++ b/apps/web/src/projectScripts.ts @@ -56,7 +56,7 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>) return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); } -export function primaryProjectScript(scripts: ProjectScript[]): ProjectScript | null { +export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null { const regular = scripts.find((script) => !script.runOnWorktreeCreate); return regular ?? scripts[0] ?? null; } diff --git a/apps/web/src/reactGrabBoundary.test.ts b/apps/web/src/reactGrabBoundary.test.ts new file mode 100644 index 00000000000..9481e2dd9fa --- /dev/null +++ b/apps/web/src/reactGrabBoundary.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import packageJson from "../package.json" with { type: "json" }; +import mainSource from "./main.tsx?raw"; + +describe("React Grab runtime boundary", () => { + it("keeps the host renderer free of the React Grab overlay", () => { + expect(mainSource).not.toMatch(/import\(["']react-grab["']\)/); + expect(packageJson.dependencies).not.toHaveProperty("react-grab"); + expect(packageJson.devDependencies).not.toHaveProperty("react-grab"); + }); +}); diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts new file mode 100644 index 00000000000..6233326dd91 --- /dev/null +++ b/apps/web/src/rightPanelStore.test.ts @@ -0,0 +1,266 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { + migratePersistedRightPanelState, + selectActiveRightPanel, + selectActiveRightPanelSurface, + selectActiveRightPanelKindWithUrl, + selectThreadRightPanelState, + useRightPanelStore, +} from "./rightPanelStore"; + +const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")); +const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); + +beforeEach(() => { + useRightPanelStore.setState({ byThreadKey: {} }); +}); + +describe("rightPanelStore", () => { + it("drops the legacy singleton terminal surface during migration", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + activeSurfaceId: "terminal", + surfaces: [ + { id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }, + { id: "terminal", kind: "terminal" }, + ], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: false, + activeSurfaceId: null, + surfaces: [{ id: "browser:tab-a", kind: "preview", resourceId: "tab-a" }], + }, + }, + }); + }); + + it("upgrades saved single-session terminal surfaces to split-capable surfaces", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "terminal:term-1", + surfaces: [{ id: "terminal:term-1", kind: "terminal", resourceId: "term-1" }], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "terminal:term-1", + surfaces: [ + { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }, + ], + }, + }, + }); + }); + + it("open sets the active panel for a thread", () => { + useRightPanelStore.getState().open(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBeNull(); + }); + + it("opening a different kind keeps both surfaces and activates the new one", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().open(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces, + ).toHaveLength(2); + }); + + it("close hides the panel without clearing its selected surface", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().close(refA); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: false, + activeSurfaceId: "plan", + surfaces: [{ id: "plan", kind: "plan" }], + }); + }); + + it("toggles empty panel visibility without creating a surface", () => { + useRightPanelStore.getState().toggleVisibility(refA); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + + useRightPanelStore.getState().toggleVisibility(refA); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); + }); + + it("toggle opens then closes the same kind", () => { + useRightPanelStore.getState().toggle(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); + useRightPanelStore.getState().toggle(refA, "preview"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("toggle to a different kind switches active", () => { + useRightPanelStore.getState().toggle(refA, "preview"); + useRightPanelStore.getState().toggle(refA, "plan"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("plan"); + }); + + it("?diff=1 always wins over persisted state", () => { + useRightPanelStore.getState().open(refA, "preview"); + expect( + selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, true), + ).toBe("diff"); + expect( + selectActiveRightPanelKindWithUrl(useRightPanelStore.getState().byThreadKey, refA, false), + ).toBe("preview"); + }); + + it("removeThread clears persisted state", () => { + useRightPanelStore.getState().open(refA, "plan"); + useRightPanelStore.getState().removeThread(refA); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBeNull(); + }); + + it("close on never-opened thread is a no-op", () => { + useRightPanelStore.getState().close(refA); + expect(useRightPanelStore.getState().byThreadKey).toEqual({}); + }); + + it("tracks one surface per browser session", () => { + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().openBrowser(refA, "tab-b"); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces.map((surface) => surface.id)).toEqual(["browser:tab-a", "browser:tab-b"]); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "browser:tab-b", + kind: "preview", + resourceId: "tab-b", + }); + }); + + it("tracks one surface per terminal session", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().openTerminal(refA, "term-2"); + + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + expect(state.surfaces).toEqual([ + { + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1"], + activeTerminalId: "term-1", + }, + { + id: "terminal:term-2", + kind: "terminal", + resourceId: "term-2", + terminalIds: ["term-2"], + activeTerminalId: "term-2", + }, + ]); + expect(state.activeSurfaceId).toBe("terminal:term-2"); + }); + + it("tracks split panes and the active pane within a terminal surface", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2"); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1", "term-2"], + activeTerminalId: "term-2", + }); + + useRightPanelStore.getState().activateTerminal(refA, "terminal:term-1", "term-1"); + useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-2"], + activeTerminalId: "term-2", + }); + }); + + it("tracks vertical layout for a terminal surface", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().splitTerminal(refA, "terminal:term-1", "term-2", "vertical"); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "terminal:term-1", + kind: "terminal", + resourceId: "term-1", + terminalIds: ["term-1", "term-2"], + activeTerminalId: "term-2", + splitDirection: "vertical", + }); + }); + + it("closing the final terminal pane removes its surface but keeps the panel open", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().closeTerminal(refA, "terminal:term-1", "term-1"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + }); + + it("closing the active surface activates a neighboring surface", () => { + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)?.id).toBe( + "browser:tab-a", + ); + }); + + it("closing the final surface leaves the panel open and empty", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().closeSurface(refA, "terminal:term-1"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: null, + surfaces: [], + }); + }); + + it("reconciles browser surfaces without deleting other surface kinds", () => { + useRightPanelStore.getState().openTerminal(refA, "term-1"); + useRightPanelStore.getState().openBrowser(refA, "tab-a"); + useRightPanelStore.getState().openBrowser(refA, "tab-b"); + useRightPanelStore.getState().reconcileBrowserSurfaces(refA, ["tab-b", "tab-c"]); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces.map( + (surface) => surface.id, + ), + ).toEqual(["terminal:term-1", "browser:tab-b", "browser:tab-c"]); + }); +}); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts new file mode 100644 index 00000000000..451751271b1 --- /dev/null +++ b/apps/web/src/rightPanelStore.ts @@ -0,0 +1,431 @@ +/** + * Thread-scoped right-panel surface state. + * + * This is intentionally a shallow workspace model: it owns an ordered set of + * surface descriptors and the active surface, while each feature continues to + * own its durable resource state. Browser surfaces point at preview tab ids, + * terminal surfaces point at terminal session ids, and diff/plan remain + * singleton surfaces. + */ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { resolveStorage } from "./lib/storage"; + +export const RIGHT_PANEL_KINDS = ["plan", "diff", "preview", "terminal"] as const; +export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; + +export type RightPanelSurface = + | { id: `browser:${string}`; kind: "preview"; resourceId: string } + | { id: "browser:new"; kind: "preview"; resourceId: null } + | { + id: `terminal:${string}`; + kind: "terminal"; + resourceId: string; + terminalIds: string[]; + activeTerminalId: string; + splitDirection?: "horizontal" | "vertical"; + } + | { id: "diff"; kind: "diff" } + | { id: "plan"; kind: "plan" }; + +const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; +const RIGHT_PANEL_STORAGE_VERSION = 5; + +export interface ThreadRightPanelState { + isOpen: boolean; + activeSurfaceId: string | null; + surfaces: RightPanelSurface[]; +} + +interface RightPanelStoreState { + byThreadKey: Record<string, ThreadRightPanelState>; + open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; + openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; + openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; + splitTerminal: ( + ref: ScopedThreadRef, + surfaceId: string, + terminalId: string, + direction?: "horizontal" | "vertical", + ) => void; + activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; + closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; + activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; + closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; + reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; + show: (ref: ScopedThreadRef) => void; + close: (ref: ScopedThreadRef) => void; + toggleVisibility: (ref: ScopedThreadRef) => void; + toggle: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "terminal">) => void; + removeThread: (ref: ScopedThreadRef) => void; +} + +const EMPTY_THREAD_STATE: ThreadRightPanelState = { + isOpen: false, + activeSurfaceId: null, + surfaces: [], +}; + +const singletonSurface = ( + kind: Exclude<RightPanelKind, "preview" | "terminal">, +): RightPanelSurface => { + switch (kind) { + case "diff": + return { id: "diff", kind }; + case "plan": + return { id: "plan", kind }; + } +}; + +const browserSurface = (tabId: string | null): RightPanelSurface => + tabId + ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } + : { id: "browser:new", kind: "preview", resourceId: null }; + +const terminalSurface = (terminalId: string): RightPanelSurface => ({ + id: `terminal:${terminalId}`, + kind: "terminal", + resourceId: terminalId, + terminalIds: [terminalId], + activeTerminalId: terminalId, +}); + +const upsertSurface = ( + current: ThreadRightPanelState, + surface: RightPanelSurface, + activate = true, +): ThreadRightPanelState => ({ + isOpen: true, + surfaces: current.surfaces.some((entry) => entry.id === surface.id) + ? current.surfaces + : [...current.surfaces, surface], + activeSurfaceId: activate ? surface.id : current.activeSurfaceId, +}); + +const updateThread = ( + byThreadKey: Record<string, ThreadRightPanelState>, + threadKey: string, + updater: (current: ThreadRightPanelState) => ThreadRightPanelState, +): Record<string, ThreadRightPanelState> => { + const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; + const next = updater(current); + if (!next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0) { + if (!(threadKey in byThreadKey)) return byThreadKey; + const { [threadKey]: _removed, ...rest } = byThreadKey; + return rest; + } + if (next === current) return byThreadKey; + return { ...byThreadKey, [threadKey]: next }; +}; + +export function migratePersistedRightPanelState(persistedState: unknown): { + byThreadKey: Record<string, ThreadRightPanelState>; +} { + if (!persistedState || typeof persistedState !== "object") { + return { byThreadKey: {} }; + } + const byThreadKey = + "byThreadKey" in persistedState && + persistedState.byThreadKey && + typeof persistedState.byThreadKey === "object" + ? Object.fromEntries( + Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>).map( + ([threadKey, threadState]) => { + const validThreadState = + threadState && typeof threadState === "object" ? threadState : null; + const surfaces = Array.isArray(validThreadState?.surfaces) + ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { + if (surface.kind !== "terminal") return [surface]; + if ( + !("resourceId" in surface) || + typeof surface.resourceId !== "string" || + surface.id !== `terminal:${surface.resourceId}` + ) { + return []; + } + const terminalIds = + "terminalIds" in surface && Array.isArray(surface.terminalIds) + ? [ + ...new Set( + surface.terminalIds.filter( + (terminalId): terminalId is string => + typeof terminalId === "string", + ), + ), + ] + : [surface.resourceId]; + const activeTerminalId = + "activeTerminalId" in surface && + typeof surface.activeTerminalId === "string" && + terminalIds.includes(surface.activeTerminalId) + ? surface.activeTerminalId + : (terminalIds[0] ?? surface.resourceId); + return [ + { + ...surface, + terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId], + activeTerminalId, + }, + ]; + }) + : []; + const activeSurfaceId = surfaces.some( + (surface) => surface.id === validThreadState?.activeSurfaceId, + ) + ? (validThreadState?.activeSurfaceId ?? null) + : null; + const isOpen = + typeof validThreadState?.isOpen === "boolean" + ? validThreadState.isOpen + : activeSurfaceId !== null; + return [threadKey, { isOpen, surfaces, activeSurfaceId }]; + }, + ), + ) + : {}; + return { byThreadKey }; +} + +export const useRightPanelStore = create<RightPanelStoreState>()( + persist( + (set) => ({ + byThreadKey: {}, + open: (ref, kind) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + if (kind === "preview") { + const existing = current.surfaces.find((surface) => surface.kind === "preview"); + return upsertSurface(current, existing ?? browserSurface(null)); + } + return upsertSurface(current, singletonSurface(kind)); + }), + })), + openBrowser: (ref, tabId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const surface = browserSurface(tabId); + const withoutPlaceholder = tabId + ? current.surfaces.filter((entry) => entry.id !== "browser:new") + : current.surfaces; + return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); + }), + })), + openTerminal: (ref, terminalId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface(current, terminalSurface(terminalId)), + ), + })), + splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + isOpen: true, + activeSurfaceId: surfaceId, + surfaces: current.surfaces.map((surface) => { + if (surface.id !== surfaceId || surface.kind !== "terminal") return surface; + const { splitDirection: _splitDirection, ...baseSurface } = surface; + return { + ...baseSurface, + terminalIds: surface.terminalIds.includes(terminalId) + ? surface.terminalIds + : [...surface.terminalIds, terminalId], + activeTerminalId: terminalId, + ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}), + }; + }), + })), + })), + activateTerminal: (ref, surfaceId, terminalId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + activeSurfaceId: surfaceId, + surfaces: current.surfaces.map((surface) => + surface.id === surfaceId && + surface.kind === "terminal" && + surface.terminalIds.includes(terminalId) + ? { ...surface, activeTerminalId: terminalId } + : surface, + ), + })), + })), + closeTerminal: (ref, surfaceId, terminalId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const surface = current.surfaces.find( + (entry) => entry.id === surfaceId && entry.kind === "terminal", + ); + if (!surface || surface.kind !== "terminal") return current; + const terminalIds = surface.terminalIds.filter((id) => id !== terminalId); + if (terminalIds.length === 0) { + const index = current.surfaces.findIndex((entry) => entry.id === surfaceId); + const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId); + const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; + return { + ...current, + surfaces, + activeSurfaceId: + current.activeSurfaceId === surfaceId + ? (fallback?.id ?? null) + : current.activeSurfaceId, + }; + } + return { + ...current, + surfaces: current.surfaces.map((entry) => + entry.id === surfaceId && entry.kind === "terminal" + ? { + ...entry, + terminalIds, + activeTerminalId: + entry.activeTerminalId === terminalId + ? (terminalIds.at(-1) ?? terminalIds[0]!) + : entry.activeTerminalId, + } + : entry, + ), + }; + }), + })), + activateSurface: (ref, surfaceId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + current.surfaces.some((surface) => surface.id === surfaceId) + ? { ...current, isOpen: true, activeSurfaceId: surfaceId } + : current, + ), + })), + closeSurface: (ref, surfaceId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); + if (index < 0) return current; + const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); + if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; + const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; + return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null }; + }), + })), + reconcileBrowserSurfaces: (ref, tabIds) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); + const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); + const existingBrowser = current.surfaces.filter( + (surface): surface is Extract<RightPanelSurface, { kind: "preview" }> => + surface.kind === "preview" && + surface.id !== "browser:new" && + validIds.has(surface.id), + ); + const knownIds = new Set(existingBrowser.map((surface) => surface.id)); + const added = tabIds + .filter((tabId) => !knownIds.has(`browser:${tabId}`)) + .map((tabId) => browserSurface(tabId)); + const surfaces = [...nonBrowser, ...existingBrowser, ...added]; + const activeStillExists = surfaces.some( + (surface) => surface.id === current.activeSurfaceId, + ); + const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); + return { + ...current, + surfaces, + activeSurfaceId: activeStillExists + ? current.activeSurfaceId + : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null), + }; + }), + })), + show: (ref) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + current.isOpen ? current : { ...current, isOpen: true }, + ), + })), + close: (ref) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + current.isOpen ? { ...current, isOpen: false } : current, + ), + })), + toggleVisibility: (ref) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + isOpen: !current.isOpen, + })), + })), + toggle: (ref, kind) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const active = current.surfaces.find( + (surface) => surface.id === current.activeSurfaceId, + ); + if (current.isOpen && active?.kind === kind) { + return { ...current, isOpen: false }; + } + if (kind === "preview") { + const existing = current.surfaces.find((surface) => surface.kind === "preview"); + return upsertSurface(current, existing ?? browserSurface(null)); + } + return upsertSurface(current, singletonSurface(kind)); + }), + })), + removeThread: (ref) => + set((state) => { + const threadKey = scopedThreadKey(ref); + if (!(threadKey in state.byThreadKey)) return state; + const { [threadKey]: _removed, ...rest } = state.byThreadKey; + return { byThreadKey: rest }; + }), + }), + { + name: RIGHT_PANEL_STORAGE_KEY, + version: RIGHT_PANEL_STORAGE_VERSION, + storage: createJSONStorage(() => + resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), + ), + partialize: (state) => ({ byThreadKey: state.byThreadKey }), + migrate: migratePersistedRightPanelState, + }, + ), +); + +export function selectThreadRightPanelState( + byThreadKey: Record<string, ThreadRightPanelState>, + ref: ScopedThreadRef | null | undefined, +): ThreadRightPanelState { + if (!ref) return EMPTY_THREAD_STATE; + return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE; +} + +export function selectActiveRightPanel( + byThreadKey: Record<string, ThreadRightPanelState>, + ref: ScopedThreadRef | null | undefined, +): RightPanelKind | null { + const state = selectThreadRightPanelState(byThreadKey, ref); + if (!state.isOpen) return null; + return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null; +} + +export function selectActiveRightPanelSurface( + byThreadKey: Record<string, ThreadRightPanelState>, + ref: ScopedThreadRef | null | undefined, +): RightPanelSurface | null { + const state = selectThreadRightPanelState(byThreadKey, ref); + if (!state.isOpen) return null; + return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null; +} + +export function selectActiveRightPanelKindWithUrl( + byThreadKey: Record<string, ThreadRightPanelState>, + ref: ScopedThreadRef | null | undefined, + diffSearchActive: boolean, +): RightPanelKind | null { + if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; + if (diffSearchActive) return "diff"; + return selectActiveRightPanel(byThreadKey, ref); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 88283d451c3..d9c94b5ae61 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,5 +1,5 @@ import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; -import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime"; +import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { Outlet, createRootRouteWithContext, @@ -7,8 +7,8 @@ import { useLocation, useNavigate, } from "@tanstack/react-router"; -import { useEffect, useEffectEvent, useRef } from "react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { QueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; @@ -16,11 +16,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; -import { - SlowRpcAckToastCoordinator, - WebSocketConnectionCoordinator, - WebSocketConnectionSurface, -} from "../components/WebSocketConnectionSurface"; +import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, @@ -29,40 +25,30 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { readLocalApi } from "../localApi"; import { useSettings } from "../hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKeyFromPath, selectProjectGroupingSettings, } from "../logicalProject"; -import { - getServerConfigUpdatedNotification, - ServerConfigUpdatedNotification, - startServerStateSync, - useServerConfig, - useServerConfigUpdatedSubscription, - useServerWelcomeSubscription, -} from "../rpc/serverState"; -import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; -import { - ensureEnvironmentConnectionBootstrapped, - getPrimaryEnvironmentConnection, - listSavedEnvironmentRecords, - waitForSavedEnvironmentRegistryHydration, - startEnvironmentConnectionService, - useSavedEnvironmentRegistryStore, -} from "../environments/runtime"; import { configureClientTracing } from "../observability/clientTracing"; -import { - ensurePrimaryEnvironmentReady, - getPrimaryKnownEnvironment, - resolveInitialServerAuthGateState, - updatePrimaryEnvironmentDescriptor, -} from "../environments/primary"; +import { resolveInitialServerAuthGateState } from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; +import { shellEnvironment } from "../state/shell"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { + primaryServerConfigAtom, + primaryServerConfigEventAtom, + primaryServerWelcomeAtom, +} from "../state/server"; +import { readProject, setActiveEnvironmentId, useActiveEnvironmentId } from "../state/entities"; +import { + createKeybindingsUpdateToastController, + type KeybindingsUpdateToastController, +} from "../components/KeybindingsUpdateToast.logic"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -77,7 +63,6 @@ export const Route = createRootRouteWithContext<{ } if (isHostedStaticApp(new URL(window.location.href))) { - await waitForSavedEnvironmentRegistryHydration(); return { authGateState: { status: "hosted-static", @@ -85,10 +70,7 @@ export const Route = createRootRouteWithContext<{ }; } - const [, authGateState] = await Promise.all([ - ensurePrimaryEnvironmentReady(), - resolveInitialServerAuthGateState(), - ]); + const authGateState = await resolveInitialServerAuthGateState(); return { authGateState, }; @@ -134,47 +116,42 @@ function RootRouteView() { <ToastProvider> <AnchoredToastProvider> {primaryEnvironmentAuthenticated ? <AuthenticatedTracingBootstrap /> : null} - {primaryEnvironmentAuthenticated ? <ServerStateBootstrap /> : null} - <EnvironmentConnectionManagerBootstrap /> <RelayClientInstallDialog /> <SshPasswordPromptDialog /> + <SlowRpcRequestToastCoordinator /> <HostedStaticEnvironmentBootstrap /> {primaryEnvironmentAuthenticated ? <EventRouter /> : null} {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null} - {primaryEnvironmentAuthenticated ? <WebSocketConnectionCoordinator /> : null} - {primaryEnvironmentAuthenticated ? <SlowRpcAckToastCoordinator /> : null} - {primaryEnvironmentAuthenticated ? ( - <WebSocketConnectionSurface>{appShell}</WebSocketConnectionSurface> - ) : ( - appShell - )} + {appShell} </AnchoredToastProvider> </ToastProvider> ); } function HostedStaticEnvironmentBootstrap() { - const savedEnvironmentCount = useSavedEnvironmentRegistryStore( - (state) => Object.keys(state.byId).length, - ); + const { environments } = useEnvironments(); + const activeEnvironmentId = useActiveEnvironmentId(); useEffect(() => { - if (getPrimaryKnownEnvironment()) { + if ( + environments.some( + (environment) => environment.entry.target._tag === "PrimaryConnectionTarget", + ) + ) { return; } - const currentActiveEnvironmentId = useStore.getState().activeEnvironmentId; - if (currentActiveEnvironmentId) { + if (activeEnvironmentId) { return; } - const firstSavedEnvironment = listSavedEnvironmentRecords()[0]; + const firstSavedEnvironment = environments[0]; if (!firstSavedEnvironment) { return; } - useStore.getState().setActiveEnvironmentId(firstSavedEnvironment.environmentId); - }, [savedEnvironmentCount]); + setActiveEnvironmentId(firstSavedEnvironment.environmentId); + }, [activeEnvironmentId, environments]); return null; } @@ -250,18 +227,6 @@ function errorDetails(error: unknown): string { } } -function ServerStateBootstrap() { - useEffect(() => { - if (!getPrimaryKnownEnvironment()) { - return; - } - - return startServerStateSync(getPrimaryEnvironmentConnection().client.server); - }, []); - - return null; -} - function AuthenticatedTracingBootstrap() { useEffect(() => { void configureClientTracing(); @@ -270,46 +235,33 @@ function AuthenticatedTracingBootstrap() { return null; } -function EnvironmentConnectionManagerBootstrap() { - const queryClient = useQueryClient(); - - useEffect(() => { - return startEnvironmentConnectionService(queryClient); - }, [queryClient]); - - return null; -} - function EventRouter() { - const setActiveEnvironmentId = useStore((store) => store.setActiveEnvironmentId); const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useSettings(selectProjectGroupingSettings); + const primaryEnvironment = usePrimaryEnvironment(); + const openInEditor = useAtomSet(shellEnvironment.openInEditor, { mode: "promise" }); + const serverConfig = useAtomValue(primaryServerConfigAtom); + const serverConfigEvent = useAtomValue(primaryServerConfigEventAtom); + const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef<string | null>(null); - const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); - const lastKeybindingsSuccessToastAtRef = useRef(0); - const disposedRef = useRef(false); - const serverConfig = useServerConfig(); + const handledConfigEventRef = useRef(serverConfigEvent); + const [keybindingsToastController] = useState<KeybindingsUpdateToastController>(() => + createKeybindingsUpdateToastController({}), + ); const handleWelcome = useEffectEvent((payload: ServerLifecycleWelcomePayload | null) => { if (!payload) return; - updatePrimaryEnvironmentDescriptor(payload.environment); setActiveEnvironmentId(payload.environment.environmentId); void (async () => { - await ensureEnvironmentConnectionBootstrapped(payload.environment.environmentId); - if (disposedRef.current) { - return; - } - if (!payload.bootstrapProjectId || !payload.bootstrapThreadId) { return; } - const bootstrapEnvironmentState = - useStore.getState().environmentStateById[payload.environment.environmentId]; - const bootstrapProject = - bootstrapEnvironmentState?.projectById[payload.bootstrapProjectId] ?? null; + const bootstrapProject = readProject( + scopeProjectRef(payload.environment.environmentId, payload.bootstrapProjectId), + ); const bootstrapProjectKey = (bootstrapProject ? deriveLogicalProjectKeyFromSettings(bootstrapProject, projectGroupingSettings) @@ -340,91 +292,79 @@ function EventRouter() { })().catch(() => undefined); }); - const handleServerConfigUpdated = useEffectEvent( - (notification: ServerConfigUpdatedNotification | null) => { - if (!notification) return; - - const { id, payload, source } = notification; - if (id <= seenServerConfigUpdateIdRef.current) { - return; - } - seenServerConfigUpdateIdRef.current = id; - if (source !== "keybindingsUpdated") { - return; - } + const handleServerConfigUpdated = useEffectEvent(() => { + const decision = keybindingsToastController.handle(serverConfigEvent); + if (!decision) { + return; + } - const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); - if (!issue) { - const now = Date.now(); - if (now - lastKeybindingsSuccessToastAtRef.current < 2_000) { - return; - } - lastKeybindingsSuccessToastAtRef.current = now; - toastManager.add({ - type: "success", - title: "Keybindings updated", - description: "Keybindings configuration reloaded successfully.", - }); - return; - } + if (decision._tag === "Success") { + toastManager.add({ + type: "success", + title: "Keybindings updated", + description: "Keybindings configuration reloaded successfully.", + }); + return; + } - toastManager.add( - stackedThreadToast({ - type: "warning", - title: "Invalid keybindings configuration", - description: issue.message, - actionVariant: "outline", - actionProps: { - children: "Open keybindings.json", - onClick: () => { - const api = readLocalApi(); - if (!api) { - return; - } - - void Promise.resolve(serverConfig ?? api.server.getConfig()) - .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); - if (!editor) { - throw new Error("No available editors found."); - } - return api.shell.openInEditor(config.keybindingsConfigPath, editor); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open keybindings file", - description: - error instanceof Error ? error.message : "Unknown error opening file.", - }), - ); - }); - }, + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Invalid keybindings configuration", + description: decision.message, + actionVariant: "outline", + actionProps: { + children: "Open keybindings.json", + onClick: () => { + if (!serverConfig || !primaryEnvironment) { + return; + } + + const editor = resolveAndPersistPreferredEditor(serverConfig.availableEditors); + if (!editor) { + return; + } + void openInEditor({ + environmentId: primaryEnvironment.environmentId, + input: { + cwd: serverConfig.keybindingsConfigPath, + editor, + }, + }).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open keybindings file", + description: + error instanceof Error ? error.message : "Unknown error opening file.", + }), + ); + }); }, - }), - ); - }, - ); + }, + }), + ); + }); useEffect(() => { if (!serverConfig) { return; } - updatePrimaryEnvironmentDescriptor(serverConfig.environment); setActiveEnvironmentId(serverConfig.environment.environmentId); - }, [serverConfig, setActiveEnvironmentId]); + }, [serverConfig]); useEffect(() => { - disposedRef.current = false; - return () => { - disposedRef.current = true; - }; - }, []); + handleWelcome(serverWelcome); + }, [serverWelcome]); - useServerWelcomeSubscription(handleWelcome); - useServerConfigUpdatedSubscription(handleServerConfigUpdated); + useEffect(() => { + if (serverConfigEvent === null || handledConfigEventRef.current === serverConfigEvent) { + return; + } + handledConfigEventRef.current = serverConfigEvent; + handleServerConfigUpdated(); + }, [serverConfigEvent]); return null; } diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 1877fee6f7d..248ee1ab8f4 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -1,5 +1,5 @@ import { createFileRoute, retainSearchParams, useNavigate } from "@tanstack/react-router"; -import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, lazy, useCallback, useEffect, useState } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; @@ -18,11 +18,12 @@ import { } from "../diffRouteSearch"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; -import { selectEnvironmentState, selectThreadExistsByRef, useStore } from "../store"; -import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { RightPanelSheet } from "../components/RightPanelSheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; +import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { environmentShell } from "../state/shell"; const DiffPanel = lazy(() => import("../components/DiffPanel")); const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; @@ -144,14 +145,15 @@ function ChatThreadRouteView() { select: (params) => resolveThreadRouteRef(params), }); const search = Route.useSearch(); - const bootstrapComplete = useStore( - (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).bootstrapComplete, - ); - const serverThread = useStore(useMemo(() => createThreadSelectorByRef(threadRef), [threadRef])); - const threadExists = useStore((store) => selectThreadExistsByRef(store, threadRef)); - const environmentHasServerThreads = useStore( - (store) => selectEnvironmentState(store, threadRef?.environmentId ?? null).threadIds.length > 0, + const shell = useEnvironmentQuery( + threadRef === null ? null : environmentShell.stateAtom(threadRef.environmentId), ); + const serverThreadShell = useThreadShell(threadRef); + const serverThreadDetail = useThreadDetail(threadRef); + const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); + const bootstrapComplete = shell.data?.snapshot._tag === "Some"; + const threadExists = serverThreadShell !== null; + const environmentHasServerThreads = environmentThreadRefs.length > 0; const draftThreadExists = useComposerDraftStore((store) => threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, ); @@ -165,7 +167,7 @@ function ChatThreadRouteView() { return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); const routeThreadExists = threadExists || draftThreadExists; - const serverThreadStarted = threadHasStarted(serverThread); + const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 77b9f18f0d7..3b84edfd42a 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -4,21 +4,23 @@ import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { useComposerDraftStore, DraftId } from "../composerDraftStore"; import { SidebarInset } from "../components/ui/sidebar"; -import { createThreadSelectorAcrossEnvironments } from "../storeSelectors"; -import { useStore } from "../store"; import { buildThreadRouteParams } from "../threadRoutes"; +import { useThreadDetail, useThreadRefs } from "../state/entities"; function DraftChatThreadRouteView() { const navigate = useNavigate(); const { draftId: rawDraftId } = Route.useParams(); const draftId = DraftId.make(rawDraftId); const draftSession = useComposerDraftStore((store) => store.getDraftSession(draftId)); - const serverThread = useStore( - useMemo( - () => createThreadSelectorAcrossEnvironments(draftSession?.threadId ?? null), - [draftSession?.threadId], - ), + const threadRefs = useThreadRefs(); + const inferredThreadRef = useMemo( + () => + draftSession + ? (threadRefs.find((ref) => ref.threadId === draftSession.threadId) ?? null) + : null, + [draftSession, threadRefs], ); + const serverThread = useThreadDetail(draftSession?.promotedTo ?? inferredThreadRef); const serverThreadStarted = threadHasStarted(serverThread); const canonicalThreadRef = useMemo( () => diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 896f66b3e93..7be0f50414e 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -5,17 +5,15 @@ import { NoActiveThreadState } from "../components/NoActiveThreadState"; import { Button } from "../components/ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { useSavedEnvironmentRegistryStore } from "../environments/runtime"; +import { useEnvironments } from "../state/environments"; import { APP_DISPLAY_NAME } from "~/branding"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); - const savedEnvironmentCount = useSavedEnvironmentRegistryStore( - (state) => Object.keys(state.byId).length, - ); + const { environments } = useEnvironments(); - if (authGateState.status === "hosted-static" && savedEnvironmentCount === 0) { + if (authGateState.status === "hosted-static" && environments.length === 0) { return <HostedStaticOnboardingState />; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 3ccb2e7f734..3436034bc9b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,31 +1,45 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { useAtomValue } from "@effect/atom-react"; import { useEffect } from "react"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { startNewLocalThreadFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; +import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { isPreviewSupportedInRuntime } from "../previewStateStore"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { useSettings } from "~/hooks/useSettings"; -import { useServerKeybindings } from "~/rpc/serverState"; +import { primaryServerKeybindingsAtom } from "~/state/server"; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); const selectedThreadKeysSize = useThreadSelectionStore((state) => state.selectedThreadKeys.size); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread, routeThreadRef } = useHandleNewThread(); - const keybindings = useServerKeybindings(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen : false, ); + // The `previewOpen` shortcut-context flag here uses the store-only value; + // the URL-aware arbitration lives inside ChatView's `onTogglePreview`, + // which we invoke via the action bus to avoid duplicating the rule. + const previewOpen = useRightPanelStore((state) => + routeThreadRef + ? selectActiveRightPanel(state.byThreadKey, routeThreadRef) === "preview" + : false, + ); const appSettings = useSettings(); useEffect(() => { @@ -35,6 +49,8 @@ function ChatRouteGlobalShortcuts() { context: { terminalFocus: isTerminalFocused(), terminalOpen, + previewFocus: isPreviewFocused(), + previewOpen, }, }); @@ -53,7 +69,7 @@ function ChatRouteGlobalShortcuts() { event.stopPropagation(); void startNewLocalThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, @@ -68,13 +84,57 @@ function ChatRouteGlobalShortcuts() { event.stopPropagation(); void startNewThreadFromContext({ activeDraftThread, - activeThread, + activeThread: activeThread ?? undefined, defaultProjectRef, defaultThreadEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), handleNewThread, }); + return; + } + + if (command === "preview.toggle") { + event.preventDefault(); + event.stopPropagation(); + if (!routeThreadRef) return; + if (!isPreviewSupportedInRuntime()) { + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Preview is desktop-only", + description: "Open T3 Code in the desktop app to use the in-app preview.", + }), + ); + return; + } + dispatchPreviewAction("toggle-panel"); + return; + } + + // The remaining preview commands only fire when the panel is the + // currently-focused tenant. The `when: previewFocus` rule already + // gates this, but defend against the keybinding being misconfigured. + if ( + command === "preview.refresh" || + command === "preview.focusUrl" || + command === "preview.zoomIn" || + command === "preview.zoomOut" || + command === "preview.resetZoom" + ) { + event.preventDefault(); + event.stopPropagation(); + const action = + command === "preview.refresh" + ? "refresh" + : command === "preview.focusUrl" + ? "focus-url" + : command === "preview.zoomIn" + ? "zoom-in" + : command === "preview.zoomOut" + ? "zoom-out" + : "reset-zoom"; + dispatchPreviewAction(action); } }; @@ -89,6 +149,8 @@ function ChatRouteGlobalShortcuts() { handleNewThread, keybindings, defaultProjectRef, + previewOpen, + routeThreadRef, selectedThreadKeysSize, terminalOpen, appSettings.defaultThreadEnvMode, diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 3f3ccc71fd9..504c93e1f78 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -1,3 +1,4 @@ +import { WS_METHODS } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -50,6 +51,13 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it("ignores the long-lived preview automation connection", () => { + trackRpcRequestSent("1", WS_METHODS.previewAutomationConnect); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }); + it("evicts the oldest pending requests once the tracker reaches capacity", () => { for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { trackRpcRequestSent(String(index), "server.getConfig"); diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index ecc3b88275c..c30ffc88279 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; @@ -21,6 +22,7 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map<string, PendingRpcAckRequest>(); +const untrackedRpcAckTags = new Set<string>([WS_METHODS.previewAutomationConnect]); const slowRpcAckRequestsAtom = Atom.make<ReadonlyArray<SlowRpcAckRequest>>([]).pipe( Atom.keepAlive, @@ -36,7 +38,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray<SlowRpcAckRequest> { } function shouldTrackRpcAck(tag: string): boolean { - return !tag.includes("subscribe"); + return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); } export function getSlowRpcAckRequests(): ReadonlyArray<SlowRpcAckRequest> { diff --git a/apps/web/src/rpc/serverState.test.ts b/apps/web/src/rpc/serverState.test.ts deleted file mode 100644 index 950ab21f57d..00000000000 --- a/apps/web/src/rpc/serverState.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { - DEFAULT_SERVER_SETTINGS, - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - ProjectId, - ThreadId, - type ServerConfig, - type ServerConfigStreamEvent, - type ServerLifecycleStreamEvent, - type ServerProvider, -} from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - getServerConfig, - getServerKeybindings, - onProvidersUpdated, - onServerConfigUpdated, - onWelcome, - resetServerStateForTests, - startServerStateSync, -} from "./serverState"; - -function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -function createDeferredPromise<T>() { - let resolve!: (value: T) => void; - const promise = new Promise<T>((nextResolve) => { - resolve = nextResolve; - }); - - return { promise, resolve }; -} - -const lifecycleListeners = new Set<(event: ServerLifecycleStreamEvent) => void>(); -const configListeners = new Set<(event: ServerConfigStreamEvent) => void>(); - -const defaultProviders: ReadonlyArray<ServerProvider> = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - enabled: true, - installed: true, - version: "0.116.0", - status: "ready", - auth: { status: "authenticated" }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [], - slashCommands: [], - skills: [], - }, -]; - -const baseEnvironment = { - environmentId: EnvironmentId.make("environment-local"), - label: "Local environment", - platform: { - os: "darwin" as const, - arch: "arm64" as const, - }, - serverVersion: "0.0.0-test", - capabilities: { - repositoryIdentity: true, - }, -}; - -const baseServerConfig: ServerConfig = { - environment: baseEnvironment, - auth: { - policy: "loopback-browser", - bootstrapMethods: ["one-time-token"], - sessionMethods: ["browser-session-cookie", "bearer-access-token"], - sessionCookieName: "t3_session", - }, - cwd: "/tmp/workspace", - keybindingsConfigPath: "/tmp/workspace/.config/keybindings.json", - keybindings: [], - issues: [], - providers: defaultProviders, - availableEditors: ["cursor"], - observability: { - logsDirectoryPath: "/tmp/workspace/.config/logs", - localTracingEnabled: true, - otlpTracesEnabled: false, - otlpMetricsEnabled: false, - }, - settings: DEFAULT_SERVER_SETTINGS, -}; - -const serverApi = { - getConfig: vi.fn<() => Promise<ServerConfig>>(), - subscribeConfig: vi.fn((listener: (event: ServerConfigStreamEvent) => void) => - registerListener(configListeners, listener), - ), - subscribeLifecycle: vi.fn((listener: (event: ServerLifecycleStreamEvent) => void) => - registerListener(lifecycleListeners, listener), - ), -}; - -function emitLifecycleEvent(event: ServerLifecycleStreamEvent) { - for (const listener of lifecycleListeners) { - listener(event); - } -} - -function emitServerConfigEvent(event: ServerConfigStreamEvent) { - for (const listener of configListeners) { - listener(event); - } -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { - const startedAt = Date.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startedAt >= timeoutMs) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -beforeEach(() => { - vi.clearAllMocks(); - lifecycleListeners.clear(); - configListeners.clear(); - resetServerStateForTests(); -}); - -afterEach(() => { - resetServerStateForTests(); -}); - -describe("serverState", () => { - it("uses default keybindings before a server config snapshot is available", () => { - expect(getServerConfig()).toBeNull(); - expect(getServerKeybindings()).toEqual(DEFAULT_RESOLVED_KEYBINDINGS); - }); - - it("bootstraps the server config snapshot and replays it to late subscribers", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - - const configListener = vi.fn(); - const stop = startServerStateSync(serverApi); - const unsubscribe = onServerConfigUpdated(configListener); - - await waitFor(() => { - expect(getServerConfig()).toEqual(baseServerConfig); - }); - - expect(serverApi.subscribeConfig).toHaveBeenCalledOnce(); - expect(serverApi.subscribeLifecycle).toHaveBeenCalledOnce(); - expect(serverApi.getConfig).toHaveBeenCalledOnce(); - expect(configListener).toHaveBeenCalledWith( - { - issues: [], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "snapshot", - ); - - const lateListener = vi.fn(); - const unsubscribeLate = onServerConfigUpdated(lateListener); - expect(lateListener).toHaveBeenCalledWith( - { - issues: [], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "snapshot", - ); - - unsubscribeLate(); - unsubscribe(); - stop(); - }); - - it("keeps the streamed snapshot when it arrives before the fallback fetch resolves", async () => { - const deferred = createDeferredPromise<ServerConfig>(); - serverApi.getConfig.mockReturnValueOnce(deferred.promise); - const stop = startServerStateSync(serverApi); - - const streamedConfig: ServerConfig = { - ...baseServerConfig, - cwd: "/tmp/from-stream", - }; - - emitServerConfigEvent({ - version: 1, - type: "snapshot", - config: streamedConfig, - }); - - await waitFor(() => { - expect(getServerConfig()).toEqual(streamedConfig); - }); - - deferred.resolve(baseServerConfig); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(getServerConfig()).toEqual(streamedConfig); - stop(); - }); - - it("replays welcome events to late subscribers", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - const stop = startServerStateSync(serverApi); - - const listener = vi.fn(); - const unsubscribe = onWelcome(listener); - - emitLifecycleEvent({ - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }, - }); - - expect(listener).toHaveBeenCalledWith({ - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }); - - const lateListener = vi.fn(); - const unsubscribeLate = onWelcome(lateListener); - expect(lateListener).toHaveBeenCalledWith({ - environment: baseEnvironment, - cwd: "/tmp/workspace", - projectName: "t3-code", - bootstrapProjectId: ProjectId.make("project-1"), - bootstrapThreadId: ThreadId.make("thread-1"), - }); - - unsubscribeLate(); - unsubscribe(); - stop(); - }); - - it("merges provider, settings, and keybinding updates into the cached config", async () => { - serverApi.getConfig.mockResolvedValueOnce(baseServerConfig); - const configListener = vi.fn(); - const providersListener = vi.fn(); - const stop = startServerStateSync(serverApi); - const unsubscribeConfig = onServerConfigUpdated(configListener); - const unsubscribeProviders = onProvidersUpdated(providersListener); - - await waitFor(() => { - expect(getServerConfig()).toEqual(baseServerConfig); - }); - - const nextProviders: ReadonlyArray<ServerProvider> = [ - { - ...defaultProviders[0]!, - status: "warning", - checkedAt: "2026-01-02T00:00:00.000Z", - message: "rate limited", - }, - ]; - - const nextKeybindings = [ - { - command: "commandPalette.toggle", - shortcut: { - key: "p", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }, - }, - ] as const; - - emitServerConfigEvent({ - version: 1, - type: "keybindingsUpdated", - payload: { - keybindings: nextKeybindings, - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - }, - }); - emitServerConfigEvent({ - version: 1, - type: "providerStatuses", - payload: { - providers: nextProviders, - }, - }); - emitServerConfigEvent({ - version: 1, - type: "settingsUpdated", - payload: { - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }, - }); - - await waitFor(() => { - expect(getServerConfig()).toEqual({ - ...baseServerConfig, - keybindings: nextKeybindings, - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }); - }); - - expect(providersListener).toHaveBeenLastCalledWith({ providers: nextProviders }); - expect(configListener).toHaveBeenNthCalledWith( - 2, - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: defaultProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "keybindingsUpdated", - ); - expect(configListener).toHaveBeenNthCalledWith( - 3, - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: DEFAULT_SERVER_SETTINGS, - }, - "providerStatuses", - ); - expect(configListener).toHaveBeenLastCalledWith( - { - issues: [{ kind: "keybindings.malformed-config", message: "bad json" }], - providers: nextProviders, - settings: { - ...DEFAULT_SERVER_SETTINGS, - enableAssistantStreaming: true, - }, - }, - "settingsUpdated", - ); - - unsubscribeProviders(); - unsubscribeConfig(); - stop(); - }); -}); diff --git a/apps/web/src/rpc/serverState.ts b/apps/web/src/rpc/serverState.ts deleted file mode 100644 index 64bc2d80e5a..00000000000 --- a/apps/web/src/rpc/serverState.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { useAtomSubscribe, useAtomValue } from "@effect/atom-react"; -import { - DEFAULT_SERVER_SETTINGS, - type EditorId, - type ServerConfig, - type ServerConfigStreamEvent, - type ServerConfigUpdatedPayload, - type ServerLifecycleWelcomePayload, - type ServerProvider, - type ServerProviderUpdatedPayload, - type ServerSettings, -} from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { Atom } from "effect/unstable/reactivity"; -import { useCallback, useRef } from "react"; - -import type { WsRpcClient } from "@t3tools/client-runtime"; -import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; - -export type ServerConfigUpdateSource = ServerConfigStreamEvent["type"]; - -export interface ServerConfigUpdatedNotification { - readonly id: number; - readonly payload: ServerConfigUpdatedPayload; - readonly source: ServerConfigUpdateSource; -} - -type ServerStateClient = Pick< - WsRpcClient["server"], - "getConfig" | "subscribeConfig" | "subscribeLifecycle" ->; - -function makeStateAtom<A>(label: string, initialValue: A) { - return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); -} - -function toServerConfigUpdatedPayload(config: ServerConfig): ServerConfigUpdatedPayload { - return { - issues: config.issues, - providers: config.providers, - settings: config.settings, - }; -} - -const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; -const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; - -const selectAvailableEditors = (config: ServerConfig | null): ReadonlyArray<EditorId> => - config?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; -const selectKeybindings = (config: ServerConfig | null) => - config?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; -const selectKeybindingsConfigPath = (config: ServerConfig | null) => - config?.keybindingsConfigPath ?? null; -const selectObservability = (config: ServerConfig | null) => config?.observability ?? null; -const selectProviders = (config: ServerConfig | null) => - config?.providers ?? EMPTY_SERVER_PROVIDERS; -const selectSettings = (config: ServerConfig | null): ServerSettings => - config?.settings ?? DEFAULT_SERVER_SETTINGS; - -export const welcomeAtom = makeStateAtom<ServerLifecycleWelcomePayload | null>( - "server-welcome", - null, -); -export const serverConfigAtom = makeStateAtom<ServerConfig | null>("server-config", null); -export const serverConfigUpdatedAtom = makeStateAtom<ServerConfigUpdatedNotification | null>( - "server-config-updated", - null, -); -export const providersUpdatedAtom = makeStateAtom<ServerProviderUpdatedPayload | null>( - "server-providers-updated", - null, -); - -export function getServerConfig(): ServerConfig | null { - return appAtomRegistry.get(serverConfigAtom); -} - -export function getServerKeybindings(): ServerConfig["keybindings"] { - return selectKeybindings(getServerConfig()); -} - -export function getServerConfigUpdatedNotification(): ServerConfigUpdatedNotification | null { - return appAtomRegistry.get(serverConfigUpdatedAtom); -} - -export function setServerConfigSnapshot(config: ServerConfig): void { - resolveServerConfig(config); - emitProvidersUpdated({ providers: config.providers }); - emitServerConfigUpdated(toServerConfigUpdatedPayload(config), "snapshot"); -} - -export function applyServerConfigEvent(event: ServerConfigStreamEvent): void { - switch (event.type) { - case "snapshot": { - setServerConfigSnapshot(event.config); - return; - } - case "keybindingsUpdated": { - const latestServerConfig = getServerConfig(); - if (!latestServerConfig) { - return; - } - const nextConfig = { - ...latestServerConfig, - keybindings: event.payload.keybindings, - issues: event.payload.issues, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), event.type); - return; - } - case "providerStatuses": { - applyProvidersUpdated(event.payload); - return; - } - case "settingsUpdated": { - applySettingsUpdated(event.payload.settings); - return; - } - } -} - -export function applyProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - const latestServerConfig = getServerConfig(); - emitProvidersUpdated(payload); - - if (!latestServerConfig) { - return; - } - - const nextConfig = { - ...latestServerConfig, - providers: payload.providers, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "providerStatuses"); -} - -export function applySettingsUpdated(settings: ServerSettings): void { - const latestServerConfig = getServerConfig(); - if (!latestServerConfig) { - return; - } - - const nextConfig = { - ...latestServerConfig, - settings, - } satisfies ServerConfig; - resolveServerConfig(nextConfig); - emitServerConfigUpdated(toServerConfigUpdatedPayload(nextConfig), "settingsUpdated"); -} - -export function emitWelcome(payload: ServerLifecycleWelcomePayload): void { - appAtomRegistry.set(welcomeAtom, payload); -} - -export function onWelcome(listener: (payload: ServerLifecycleWelcomePayload) => void): () => void { - return subscribeLatest(welcomeAtom, listener); -} - -export function onServerConfigUpdated( - listener: (payload: ServerConfigUpdatedPayload, source: ServerConfigUpdateSource) => void, -): () => void { - return subscribeLatest(serverConfigUpdatedAtom, (notification) => { - listener(notification.payload, notification.source); - }); -} - -export function onProvidersUpdated( - listener: (payload: ServerProviderUpdatedPayload) => void, -): () => void { - return subscribeLatest(providersUpdatedAtom, listener); -} - -export function startServerStateSync(client: ServerStateClient): () => void { - let disposed = false; - const cleanups = [ - client.subscribeLifecycle((event) => { - if (event.type === "welcome") { - emitWelcome(event.payload); - } - }), - client.subscribeConfig((event) => { - applyServerConfigEvent(event); - }), - ]; - - if (getServerConfig() === null) { - void client - .getConfig() - .then((config) => { - if (disposed || getServerConfig() !== null) { - return; - } - setServerConfigSnapshot(config); - }) - .catch(() => undefined); - } - - return () => { - disposed = true; - for (const cleanup of cleanups) { - cleanup(); - } - }; -} - -export function resetServerStateForTests() { - resetAppAtomRegistryForTests(); - nextServerConfigUpdatedNotificationId = 1; -} - -let nextServerConfigUpdatedNotificationId = 1; - -function resolveServerConfig(config: ServerConfig): void { - appAtomRegistry.set(serverConfigAtom, config); -} - -function emitProvidersUpdated(payload: ServerProviderUpdatedPayload): void { - appAtomRegistry.set(providersUpdatedAtom, payload); -} - -function emitServerConfigUpdated( - payload: ServerConfigUpdatedPayload, - source: ServerConfigUpdateSource, -): void { - appAtomRegistry.set(serverConfigUpdatedAtom, { - id: nextServerConfigUpdatedNotificationId++, - payload, - source, - }); -} - -function subscribeLatest<A>( - atom: Atom.Atom<A | null>, - listener: (value: NonNullable<A>) => void, -): () => void { - return appAtomRegistry.subscribe( - atom, - (value) => { - if (value === null) { - return; - } - listener(value as NonNullable<A>); - }, - { immediate: true }, - ); -} - -function useLatestAtomSubscription<A>( - atom: Atom.Atom<A | null>, - listener: (value: NonNullable<A>) => void, -): void { - const listenerRef = useRef(listener); - listenerRef.current = listener; - - const stableListener = useCallback((value: A | null) => { - if (value === null) { - return; - } - listenerRef.current(value as NonNullable<A>); - }, []); - - useAtomSubscribe(atom, stableListener, { immediate: true }); -} - -export function useServerConfig(): ServerConfig | null { - return useAtomValue(serverConfigAtom); -} - -export function useServerSettings(): ServerSettings { - return useAtomValue(serverConfigAtom, selectSettings); -} - -export function useServerProviders(): ReadonlyArray<ServerProvider> { - return useAtomValue(serverConfigAtom, selectProviders); -} - -export function useServerKeybindings(): ServerConfig["keybindings"] { - return useAtomValue(serverConfigAtom, selectKeybindings); -} - -export function useServerAvailableEditors(): ReadonlyArray<EditorId> { - return useAtomValue(serverConfigAtom, selectAvailableEditors); -} - -export function useServerKeybindingsConfigPath(): string | null { - return useAtomValue(serverConfigAtom, selectKeybindingsConfigPath); -} - -export function useServerObservability(): ServerConfig["observability"] | null { - return useAtomValue(serverConfigAtom, selectObservability); -} - -export function useServerWelcomeSubscription( - listener: (payload: ServerLifecycleWelcomePayload) => void, -): void { - useLatestAtomSubscription(welcomeAtom, listener); -} - -export function useServerConfigUpdatedSubscription( - listener: (notification: ServerConfigUpdatedNotification) => void, -): void { - useLatestAtomSubscription(serverConfigUpdatedAtom, listener); -} diff --git a/apps/web/src/rpc/transportError.ts b/apps/web/src/rpc/transportError.ts index 649d06f3a70..493de5f93bd 100644 --- a/apps/web/src/rpc/transportError.ts +++ b/apps/web/src/rpc/transportError.ts @@ -1,4 +1,4 @@ export { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage, -} from "@t3tools/client-runtime"; +} from "@t3tools/client-runtime/errors"; diff --git a/apps/web/src/rpc/wsConnectionState.test.ts b/apps/web/src/rpc/wsConnectionState.test.ts deleted file mode 100644 index efb4d6e62f3..00000000000 --- a/apps/web/src/rpc/wsConnectionState.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - getWsConnectionStatus, - getWsReconnectDelayMsForRetry, - getWsConnectionUiState, - recordWsConnectionAttempt, - recordWsConnectionClosed, - recordWsConnectionErrored, - recordWsConnectionOpened, - resetWsConnectionStateForTests, - setBrowserOnlineStatus, - WS_RECONNECT_MAX_ATTEMPTS, -} from "./wsConnectionState"; - -describe("wsConnectionState", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-03T20:30:00.000Z")); - resetWsConnectionStateForTests(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("treats a disconnected browser as offline once the websocket drops", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionOpened(); - recordWsConnectionClosed({ code: 1006, reason: "offline" }); - setBrowserOnlineStatus(false); - - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("offline"); - }); - - it("stays in the initial connecting state until the first disconnect", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 1, - hasConnected: false, - phase: "connecting", - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("connecting"); - }); - - it("schedules the next retry after a failed websocket attempt", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws", { - connectionLabel: "Remote Mac", - }); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); - - const firstRetryDelayMs = getWsReconnectDelayMsForRetry(0); - if (firstRetryDelayMs === null) { - throw new Error("Expected an initial retry delay."); - } - - expect(getWsConnectionStatus()).toMatchObject({ - connectionLabel: "Remote Mac", - nextRetryAt: new Date(Date.now() + firstRetryDelayMs).toISOString(), - reconnectAttemptCount: 1, - reconnectPhase: "waiting", - }); - }); - - it("adds a version mismatch hint to websocket errors when metadata includes one", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws", { - connectionLabel: "Remote Mac", - }); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket.", { - versionMismatchHint: "Version mismatch. Try syncing the client and server.", - }); - - expect(getWsConnectionStatus()).toMatchObject({ - lastError: - "Unable to connect to the T3 server WebSocket. Hint: Version mismatch. Try syncing the client and server.", - }); - }); - - it("adds a version mismatch hint to websocket close reasons when metadata includes one", () => { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionOpened(); - recordWsConnectionClosed( - { code: 1006, reason: "socket closed" }, - { - versionMismatchHint: "Version mismatch. Try syncing the client and server.", - }, - ); - - expect(getWsConnectionStatus()).toMatchObject({ - closeReason: "socket closed Hint: Version mismatch. Try syncing the client and server.", - }); - }); - - it("marks the reconnect cycle as exhausted after the final attempt fails", () => { - for (let attempt = 0; attempt < WS_RECONNECT_MAX_ATTEMPTS; attempt += 1) { - recordWsConnectionAttempt("ws://localhost:3020/ws"); - recordWsConnectionErrored("Unable to connect to the T3 server WebSocket."); - } - - expect(getWsConnectionStatus()).toMatchObject({ - nextRetryAt: null, - reconnectAttemptCount: WS_RECONNECT_MAX_ATTEMPTS, - reconnectPhase: "exhausted", - }); - }); -}); diff --git a/apps/web/src/rpc/wsConnectionState.ts b/apps/web/src/rpc/wsConnectionState.ts deleted file mode 100644 index 9e67f461184..00000000000 --- a/apps/web/src/rpc/wsConnectionState.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { DEFAULT_RECONNECT_BACKOFF, getReconnectDelayMs } from "@t3tools/client-runtime"; -import { Atom } from "effect/unstable/reactivity"; - -import { appAtomRegistry } from "./atomRegistry"; - -export type WsConnectionUiState = "connected" | "connecting" | "error" | "offline" | "reconnecting"; -export type WsReconnectPhase = "attempting" | "exhausted" | "idle" | "waiting"; - -export const WS_RECONNECT_INITIAL_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.initialDelayMs; -export const WS_RECONNECT_BACKOFF_FACTOR = DEFAULT_RECONNECT_BACKOFF.backoffFactor; -export const WS_RECONNECT_MAX_DELAY_MS = DEFAULT_RECONNECT_BACKOFF.maxDelayMs; -export const WS_RECONNECT_MAX_RETRIES = DEFAULT_RECONNECT_BACKOFF.maxRetries!; -export const WS_RECONNECT_MAX_ATTEMPTS = WS_RECONNECT_MAX_RETRIES + 1; - -export interface WsConnectionStatus { - readonly attemptCount: number; - readonly closeCode: number | null; - readonly closeReason: string | null; - readonly connectionLabel: string | null; - readonly connectedAt: string | null; - readonly disconnectedAt: string | null; - readonly hasConnected: boolean; - readonly lastError: string | null; - readonly lastErrorAt: string | null; - readonly nextRetryAt: string | null; - readonly online: boolean; - readonly phase: "idle" | "connecting" | "connected" | "disconnected"; - readonly reconnectAttemptCount: number; - readonly reconnectMaxAttempts: number; - readonly reconnectPhase: WsReconnectPhase; - readonly socketUrl: string | null; -} - -const INITIAL_WS_CONNECTION_STATUS = Object.freeze<WsConnectionStatus>({ - attemptCount: 0, - closeCode: null, - closeReason: null, - connectionLabel: null, - connectedAt: null, - disconnectedAt: null, - hasConnected: false, - lastError: null, - lastErrorAt: null, - nextRetryAt: null, - online: typeof navigator === "undefined" ? true : navigator.onLine !== false, - phase: "idle", - reconnectAttemptCount: 0, - reconnectMaxAttempts: WS_RECONNECT_MAX_ATTEMPTS, - reconnectPhase: "idle", - socketUrl: null, -}); - -export const wsConnectionStatusAtom = Atom.make(INITIAL_WS_CONNECTION_STATUS).pipe( - Atom.keepAlive, - Atom.withLabel("ws-connection-status"), -); - -function isoNow() { - return new Date().toISOString(); -} - -function updateWsConnectionStatus( - updater: (current: WsConnectionStatus) => WsConnectionStatus, -): WsConnectionStatus { - const nextStatus = updater(getWsConnectionStatus()); - appAtomRegistry.set(wsConnectionStatusAtom, nextStatus); - return nextStatus; -} - -export interface WsConnectionMetadata { - readonly connectionLabel?: string | null; - readonly versionMismatchHint?: string | null; -} - -function normalizeConnectionLabel(label: string | null | undefined): string | null { - const normalized = label?.trim(); - return normalized ? normalized : null; -} - -export function getWsConnectionStatus(): WsConnectionStatus { - return appAtomRegistry.get(wsConnectionStatusAtom); -} - -export function getWsConnectionUiState(status: WsConnectionStatus): WsConnectionUiState { - if (status.phase === "connected") { - return "connected"; - } - - if (!status.online && (status.disconnectedAt !== null || status.phase === "disconnected")) { - return "offline"; - } - - if (!status.hasConnected) { - return status.phase === "disconnected" ? "error" : "connecting"; - } - - return "reconnecting"; -} - -export function recordWsConnectionAttempt( - socketUrl: string, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => ({ - ...current, - attemptCount: current.attemptCount + 1, - connectionLabel: connectionLabel ?? current.connectionLabel, - nextRetryAt: null, - phase: "connecting", - reconnectAttemptCount: current.phase === "connected" ? 1 : current.reconnectAttemptCount + 1, - reconnectPhase: "attempting", - socketUrl, - })); -} - -export function recordWsConnectionOpened(metadata?: WsConnectionMetadata): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => ({ - ...current, - closeCode: null, - closeReason: null, - connectionLabel: connectionLabel ?? current.connectionLabel, - connectedAt: isoNow(), - disconnectedAt: null, - hasConnected: true, - nextRetryAt: null, - phase: "connected", - reconnectAttemptCount: 0, - reconnectPhase: "idle", - })); -} - -function appendHint(message: string | null | undefined, hint: string | null | undefined) { - const normalizedMessage = message?.trim(); - const normalizedHint = hint?.trim(); - if (!normalizedMessage) { - return normalizedHint ? `Hint: ${normalizedHint}` : null; - } - return normalizedHint ? `${normalizedMessage} Hint: ${normalizedHint}` : normalizedMessage; -} - -export function recordWsConnectionErrored( - message?: string | null, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - return updateWsConnectionStatus((current) => - applyDisconnectState(current, { - lastError: - appendHint(message, metadata?.versionMismatchHint) ?? - appendHint(current.lastError, metadata?.versionMismatchHint), - lastErrorAt: isoNow(), - }), - ); -} - -export function recordWsConnectionClosed( - details?: { - readonly code?: number; - readonly reason?: string; - }, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const connectionLabel = normalizeConnectionLabel(metadata?.connectionLabel); - return updateWsConnectionStatus((current) => - applyDisconnectState( - current, - { - closeCode: details?.code ?? current.closeCode, - closeReason: - appendHint(details?.reason, metadata?.versionMismatchHint) ?? - appendHint(current.closeReason, metadata?.versionMismatchHint), - }, - connectionLabel === null ? undefined : { connectionLabel }, - ), - ); -} - -export function setBrowserOnlineStatus(online: boolean): WsConnectionStatus { - return updateWsConnectionStatus((current) => ({ - ...current, - online, - })); -} - -export function resetWsReconnectBackoff(): WsConnectionStatus { - return updateWsConnectionStatus((current) => ({ - ...current, - nextRetryAt: null, - reconnectAttemptCount: 0, - reconnectPhase: "idle", - })); -} - -export function resetWsConnectionStateForTests(): void { - appAtomRegistry.set(wsConnectionStatusAtom, INITIAL_WS_CONNECTION_STATUS); -} - -export function useWsConnectionStatus(): WsConnectionStatus { - return useAtomValue(wsConnectionStatusAtom); -} - -export function getWsReconnectDelayMsForRetry(retryIndex: number): number | null { - return getReconnectDelayMs(retryIndex); -} - -function applyDisconnectState( - current: WsConnectionStatus, - updates: Partial< - Pick<WsConnectionStatus, "closeCode" | "closeReason" | "lastError" | "lastErrorAt"> - >, - metadata?: WsConnectionMetadata, -): WsConnectionStatus { - const disconnectedAt = current.disconnectedAt ?? isoNow(); - const nextRetryDelayMs = - current.nextRetryAt !== null || current.reconnectPhase === "exhausted" - ? null - : getWsReconnectDelayMsForRetry(Math.max(0, current.reconnectAttemptCount - 1)); - - return { - ...current, - ...updates, - connectionLabel: normalizeConnectionLabel(metadata?.connectionLabel) ?? current.connectionLabel, - disconnectedAt, - nextRetryAt: - nextRetryDelayMs === null - ? current.nextRetryAt - : new Date(Date.now() + nextRetryDelayMs).toISOString(), - phase: "disconnected", - reconnectPhase: - current.reconnectPhase === "waiting" || current.reconnectPhase === "exhausted" - ? current.reconnectPhase - : nextRetryDelayMs === null - ? "exhausted" - : "waiting", - }; -} diff --git a/apps/web/src/rpc/wsTransport.test.ts b/apps/web/src/rpc/wsTransport.test.ts deleted file mode 100644 index eb6fb494da2..00000000000 --- a/apps/web/src/rpc/wsTransport.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { DEFAULT_SERVER_SETTINGS, ServerSettings, WS_METHODS } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - __resetClientTracingForTests, - configureClientTracing, -} from "../observability/clientTracing"; -import { - getSlowRpcAckRequests, - resetRequestLatencyStateForTests, - setSlowRpcAckThresholdMsForTests, -} from "../rpc/requestLatencyState"; -import { - getWsConnectionStatus, - getWsConnectionUiState, - resetWsConnectionStateForTests, -} from "../rpc/wsConnectionState"; -import { WsTransport } from "./wsTransport"; - -const encodeServerSettings = Schema.encodeSync(ServerSettings); - -type WsEventType = "open" | "message" | "close" | "error"; -type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; -type WsListener = (event?: WsEvent) => void; - -const sockets: MockWebSocket[] = []; - -class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readyState = MockWebSocket.CONNECTING; - readonly sent: string[] = []; - readonly url: string; - private readonly listeners = new Map<WsEventType, Set<WsListener>>(); - - constructor(url: string) { - this.url = url; - sockets.push(this); - } - - addEventListener(type: WsEventType, listener: WsListener) { - const listeners = this.listeners.get(type) ?? new Set<WsListener>(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - removeEventListener(type: WsEventType, listener: WsListener) { - this.listeners.get(type)?.delete(listener); - } - - send(data: string) { - this.sent.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", { code, reason, type: "close" }); - } - - open() { - this.readyState = MockWebSocket.OPEN; - this.emit("open", { type: "open" }); - } - - serverMessage(data: unknown) { - this.emit("message", { data, type: "message" }); - } - - error() { - this.emit("error", { type: "error" }); - } - - private emit(type: WsEventType, event?: WsEvent) { - const listeners = this.listeners.get(type); - if (!listeners) return; - for (const listener of listeners) { - listener(event); - } - } -} - -const originalWebSocket = globalThis.WebSocket; -const originalFetch = globalThis.fetch; -const transports: WsTransport[] = []; - -function getSocket(): MockWebSocket { - const socket = sockets.at(-1); - if (!socket) { - throw new Error("Expected a websocket instance"); - } - return socket; -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise<void> { - const startedAt = Date.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (Date.now() - startedAt >= timeoutMs) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -function createTransport(...args: ConstructorParameters<typeof WsTransport>): WsTransport { - const transport = new WsTransport(...args); - transports.push(transport); - return transport; -} - -beforeEach(() => { - vi.useRealTimers(); - sockets.length = 0; - transports.length = 0; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - location: { - origin: "http://localhost:3020", - hostname: "localhost", - port: "3020", - protocol: "http:", - }, - desktopBridge: undefined, - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { onLine: true }, - }); - - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; -}); - -afterEach(async () => { - await Promise.allSettled(transports.map((transport) => transport.dispose())); - transports.length = 0; - globalThis.WebSocket = originalWebSocket; - globalThis.fetch = originalFetch; - resetRequestLatencyStateForTests(); - resetWsConnectionStateForTests(); - await __resetClientTracingForTests(); - vi.restoreAllMocks(); -}); - -describe("WsTransport (web instrumentation)", () => { - it("tracks initial connection failures for the app error state", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 1, - phase: "connecting", - socketUrl: "ws://localhost:3020/ws", - }); - - socket.error(); - socket.close(1006, "server unavailable"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeCode: 1006, - closeReason: "server unavailable", - hasConnected: false, - lastError: "Unable to connect to the T3 server WebSocket.", - phase: "disconnected", - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("error"); - - await transport.dispose(); - }); - - it("surfaces reconnecting state after a live socket disconnects", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - socket.close(1013, "try again later"); - - await waitFor(() => { - expect(getWsConnectionStatus()).toMatchObject({ - closeReason: "try again later", - hasConnected: true, - }); - }); - expect(getWsConnectionUiState(getWsConnectionStatus())).toBe("reconnecting"); - - await transport.dispose(); - }); - - it("composes custom lifecycle handlers with default websocket state tracking", async () => { - const onOpen = vi.fn(); - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { - onOpen, - onClose, - }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(onOpen).toHaveBeenCalledOnce(); - expect(getWsConnectionStatus()).toMatchObject({ - hasConnected: true, - phase: "connected", - }); - }); - - socket.close(1012, "service restart"); - - await waitFor(() => { - expect(onClose).toHaveBeenCalledWith( - { - code: 1012, - reason: "service restart", - }, - { - intentional: false, - }, - ); - expect(getWsConnectionStatus()).toMatchObject({ - attemptCount: 2, - closeReason: "service restart", - phase: "connecting", - }); - }, 2_000); - - await transport.dispose(); - }); - - it("marks unary requests as slow until the first server ack arrives", async () => { - const slowAckThresholdMs = 25; - setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - await waitFor(() => { - expect(getSlowRpcAckRequests()).toMatchObject([ - { - requestId: requestMessage.id, - tag: WS_METHODS.serverUpsertKeybinding, - }, - ]); - }, 1_000); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - expect(getSlowRpcAckRequests()).toEqual([]); - - await transport.dispose(); - }, 5_000); - - it("clears slow unary request tracking when the transport reconnects", async () => { - const slowAckThresholdMs = 25; - setSlowRpcAckThresholdMsForTests(slowAckThresholdMs); - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await waitFor(() => { - expect(firstSocket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; - - await waitFor(() => { - expect(getSlowRpcAckRequests()).toMatchObject([ - { - requestId: firstRequest.id, - tag: WS_METHODS.serverUpsertKeybinding, - }, - ]); - }, 1_000); - - void requestPromise.catch(() => undefined); - - await transport.reconnect(); - - expect(getSlowRpcAckRequests()).toEqual([]); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - secondSocket.open(); - - await transport.dispose(); - }, 5_000); - - it("propagates OTLP trace ids for ws transport requests when client tracing is enabled", async () => { - await configureClientTracing({ - exportIntervalMs: 10, - }); - - const transport = createTransport("ws://localhost:3020"); - const requestPromise = transport.request((client) => client[WS_METHODS.serverGetSettings]({})); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - id: string; - spanId?: string; - traceId?: string; - }; - expect(requestMessage.traceId).toMatch(/^[0-9a-f]{32}$/); - expect(requestMessage.spanId).toMatch(/^[0-9a-f]{16}$/); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: encodeServerSettings(DEFAULT_SERVER_SETTINGS), - }, - }), - ); - - await expect(requestPromise).resolves.toEqual(DEFAULT_SERVER_SETTINGS); - await transport.dispose(); - }); -}); diff --git a/apps/web/src/rpc/wsTransport.ts b/apps/web/src/rpc/wsTransport.ts deleted file mode 100644 index 7c3b4303f3a..00000000000 --- a/apps/web/src/rpc/wsTransport.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - WsTransport as BaseWsTransport, - type WsProtocolLifecycleHandlers, - type WsRpcProtocolSocketUrlProvider, - type WsTransportOptions, -} from "@t3tools/client-runtime"; -import { createWsRpcProtocolLayer as createSharedWsRpcProtocolLayer } from "@t3tools/client-runtime"; - -import { ClientTracingLive } from "../observability/clientTracing"; -import { - acknowledgeRpcRequest, - clearAllTrackedRpcRequests, - trackRpcRequestSent, -} from "./requestLatencyState"; -import { - recordWsConnectionAttempt, - recordWsConnectionClosed, - recordWsConnectionErrored, - recordWsConnectionOpened, -} from "./wsConnectionState"; - -function createWsRpcProtocolLayer( - url: WsRpcProtocolSocketUrlProvider, - handlers?: WsProtocolLifecycleHandlers, -) { - return createSharedWsRpcProtocolLayer(url, handlers, { - telemetryLifecycle: { - onAttempt: recordWsConnectionAttempt, - onOpen: recordWsConnectionOpened, - onError: (message) => { - clearAllTrackedRpcRequests(); - recordWsConnectionErrored(message); - }, - onClose: (details, context) => { - clearAllTrackedRpcRequests(); - if (context.intentional) { - return; - } - recordWsConnectionClosed(details); - }, - }, - requestTelemetry: { - onRequestSent: trackRpcRequestSent, - onRequestAcknowledged: acknowledgeRpcRequest, - onClearTrackedRequests: clearAllTrackedRpcRequests, - }, - }); -} - -const webWsTransportOptions = { - tracingLayer: ClientTracingLive, - createProtocolLayer: createWsRpcProtocolLayer, - onBeforeReconnect: () => clearAllTrackedRpcRequests(), -} satisfies WsTransportOptions; - -export class WsTransport extends BaseWsTransport { - constructor( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - ) { - super(url, lifecycleHandlers, webWsTransportOptions); - } -} diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index fa7008c73ff..0f12e672f66 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -300,7 +300,7 @@ describe("derivePendingUserInputs", () => { payload: { requestId: "req-user-input-stale-1", detail: - "Stale pending user-input request: req-user-input-stale-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", + "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1", }, }), ]; @@ -934,6 +934,67 @@ describe("deriveWorkLogEntries", () => { expect(entry?.toolLifecycleStatus).toBe("completed"); }); + it("preserves MCP server, tool, arguments, and results for expanded display", () => { + const item = { + type: "mcpToolCall", + server: "t3-code", + tool: "preview_status", + arguments: {}, + status: "completed", + result: { content: [{ type: "text", text: "attached" }] }, + }; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "mcp-tool-done", + kind: "tool.completed", + summary: "t3-code · preview_status", + payload: { + itemType: "mcp_tool_call", + title: "t3-code · preview_status", + data: { item }, + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolTitle).toBe("t3-code · preview_status"); + expect(entry?.toolData).toEqual(item); + }); + + it("keeps MCP payloads while collapsing lifecycle updates", () => { + const item = { + type: "mcpToolCall", + server: "t3-code", + tool: "preview_snapshot", + arguments: { interactiveOnly: true }, + status: "completed", + }; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "mcp-tool-progress", + kind: "tool.updated", + summary: "t3-code · preview_snapshot", + payload: { + itemType: "mcp_tool_call", + toolCallId: "call-1", + data: { item }, + }, + }), + makeActivity({ + id: "mcp-tool-complete", + kind: "tool.completed", + summary: "t3-code · preview_snapshot", + payload: { + itemType: "mcp_tool_call", + toolCallId: "call-1", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities); + expect(entry?.toolData).toEqual(item); + }); + it("unwraps PowerShell command wrappers for displayed command text", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1440,6 +1501,8 @@ describe("deriveTimelineEntries", () => { role: "assistant", text: "hello", createdAt: "2026-02-23T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-23T00:00:01.000Z", streaming: false, }, ], @@ -1525,7 +1588,7 @@ describe("isLatestTurnSettled", () => { it("returns false while the same turn is still active in a running session", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-1"), }), ).toBe(false); @@ -1534,7 +1597,7 @@ describe("isLatestTurnSettled", () => { it("returns false while any turn is running to avoid stale latest-turn banners", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-2"), }), ).toBe(false); @@ -1543,8 +1606,8 @@ describe("isLatestTurnSettled", () => { it("returns true once the session is no longer running that turn", () => { expect( isLatestTurnSettled(latestTurn, { - orchestrationStatus: "ready", - activeTurnId: undefined, + status: "ready", + activeTurnId: null, }), ).toBe(true); }); @@ -1575,7 +1638,7 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-1"), }, "2026-02-27T21:11:00.000Z", @@ -1588,7 +1651,7 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "running", + status: "running", activeTurnId: TurnId.make("turn-2"), }, "2026-02-27T21:11:00.000Z", @@ -1601,8 +1664,8 @@ describe("deriveActiveWorkStartedAt", () => { deriveActiveWorkStartedAt( latestTurn, { - orchestrationStatus: "ready", - activeTurnId: undefined, + status: "ready", + activeTurnId: null, }, "2026-02-27T21:11:00.000Z", ), diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 63ab93c48ee..5d5051f748e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -71,6 +71,7 @@ export interface WorkLogEntry { changedFiles?: ReadonlyArray<string>; tone: "thinking" | "tool" | "info" | "error"; toolTitle?: string; + toolData?: unknown; itemType?: ToolLifecycleItemType; requestKind?: PendingApproval["requestKind"]; /** From runtime item / task payload `status` when present (e.g. tool.updated). */ @@ -288,7 +289,7 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str } type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; -type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">; +type SessionActivityState = Pick<NonNullable<Thread["session"]>, "status" | "activeTurnId">; export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, @@ -297,7 +298,7 @@ export function isLatestTurnSettled( if (!latestTurn?.startedAt) return false; if (!latestTurn.completedAt) return false; if (!session) return true; - if (session.orchestrationStatus === "running") return false; + if (session.status === "running") return false; return true; } @@ -306,8 +307,7 @@ export function deriveActiveWorkStartedAt( session: SessionActivityState | null, sendStartedAt: string | null, ): string | null { - const runningTurnId = - session?.orchestrationStatus === "running" ? (session.activeTurnId ?? null) : null; + const runningTurnId = session?.status === "running" ? session.activeTurnId : null; if (runningTurnId !== null) { if (latestTurn?.turnId === runningTurnId) { return latestTurn.startedAt ?? sendStartedAt; @@ -346,7 +346,9 @@ function isStalePendingRequestFailureDetail(detail: string | undefined): boolean normalized.includes("stale pending user-input request") || normalized.includes("unknown pending approval request") || normalized.includes("unknown pending permission request") || - normalized.includes("unknown pending user-input request") + normalized.includes("unknown pending user-input request") || + normalized.includes("unknown pending user input request") || + normalized.includes("unknown pending codex user input request") ); } @@ -732,6 +734,12 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (title) { entry.toolTitle = title; } + if (itemType === "mcp_tool_call") { + const data = asRecord(payload?.data); + if (data?.item !== undefined) { + entry.toolData = data.item; + } + } if (itemType) { entry.itemType = itemType; } @@ -809,6 +817,7 @@ function mergeDerivedWorkLogEntries( const collapseKey = next.collapseKey ?? previous.collapseKey; const toolCallId = next.toolCallId ?? previous.toolCallId; const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus; + const toolData = next.toolData ?? previous.toolData; return { ...previous, ...next, @@ -822,6 +831,7 @@ function mergeDerivedWorkLogEntries( ...(collapseKey ? { collapseKey } : {}), ...(toolCallId ? { toolCallId } : {}), ...(toolLifecycleStatus !== undefined ? { toolLifecycleStatus } : {}), + ...(toolData !== undefined ? { toolData } : {}), }; } @@ -1328,9 +1338,9 @@ function compareActivityLifecycleRank(kind: string): number { } export function deriveTimelineEntries( - messages: ChatMessage[], - proposedPlans: ProposedPlan[], - workEntries: WorkLogEntry[], + messages: ReadonlyArray<ChatMessage>, + proposedPlans: ReadonlyArray<ProposedPlan>, + workEntries: ReadonlyArray<WorkLogEntry>, ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1356,7 +1366,7 @@ export function deriveTimelineEntries( } export function inferCheckpointTurnCountByTurnId( - summaries: TurnDiffSummary[], + summaries: ReadonlyArray<TurnDiffSummary>, ): Record<TurnId, number> { const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); const result: Record<TurnId, number> = {}; @@ -1369,8 +1379,15 @@ export function inferCheckpointTurnCountByTurnId( } export function derivePhase(session: ThreadSession | null): SessionPhase { - if (!session || session.status === "closed") return "disconnected"; - if (session.status === "connecting") return "connecting"; + if ( + !session || + session.status === "stopped" || + session.status === "interrupted" || + session.status === "error" + ) { + return "disconnected"; + } + if (session.status === "starting") return "connecting"; if (session.status === "running") return "running"; return "ready"; } diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8909c1bf755..c90cf51d969 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,4 +1,4 @@ -import { scopeProjectRef } from "@t3tools/client-runtime"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; import { deriveLogicalProjectKeyFromSettings, @@ -104,7 +104,7 @@ export function buildSidebarProjectSnapshots(input: { representative, members, }) - : representative.name, + : representative.title, groupedProjectCount: members.length, environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts new file mode 100644 index 00000000000..5e31beb826b --- /dev/null +++ b/apps/web/src/state/assets.ts @@ -0,0 +1,5 @@ +import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/auth.ts b/apps/web/src/state/auth.ts new file mode 100644 index 00000000000..835dee7f783 --- /dev/null +++ b/apps/web/src/state/auth.ts @@ -0,0 +1,5 @@ +import { createAuthEnvironmentAtoms } from "@t3tools/client-runtime/state/auth"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const authEnvironment = createAuthEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/cloud.ts b/apps/web/src/state/cloud.ts new file mode 100644 index 00000000000..a11fa1cb2e6 --- /dev/null +++ b/apps/web/src/state/cloud.ts @@ -0,0 +1,5 @@ +import { createCloudEnvironmentAtoms } from "@t3tools/client-runtime/state/cloud"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const cloudEnvironment = createCloudEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts new file mode 100644 index 00000000000..2d99ad84389 --- /dev/null +++ b/apps/web/src/state/entities.ts @@ -0,0 +1,188 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentProject, + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import type { + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, + OrchestrationThreadActivity, + ScopedProjectRef, + ScopedThreadRef, +} from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentProjects } from "./projects"; +import { environmentThreadDetails, environmentThreadShells } from "./threads"; + +const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]); +const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); +const EMPTY_MESSAGES: ReadonlyArray<OrchestrationMessage> = Object.freeze([]); +const EMPTY_ACTIVITIES: ReadonlyArray<OrchestrationThreadActivity> = Object.freeze([]); +const EMPTY_PROPOSED_PLANS: ReadonlyArray<OrchestrationProposedPlan> = Object.freeze([]); + +const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( + Atom.withLabel("web-project:empty"), +); +const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( + Atom.withLabel("web-project-refs:empty"), +); +const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( + Atom.withLabel("web-thread-refs:empty"), +); +const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).pipe( + Atom.withLabel("web-thread-shell:empty"), +); +const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe( + Atom.withLabel("web-thread-detail:empty"), +); +const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( + Atom.withLabel("web-thread-messages:empty"), +); +const EMPTY_ACTIVITIES_ATOM = Atom.make(EMPTY_ACTIVITIES).pipe( + Atom.withLabel("web-thread-activities:empty"), +); +const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( + Atom.withLabel("web-thread-proposed-plans:empty"), +); +const EMPTY_SESSION_ATOM = Atom.make<OrchestrationSession | null>(null).pipe( + Atom.withLabel("web-thread-session:empty"), +); + +export const activeEnvironmentIdAtom = Atom.make<EnvironmentId | null>(null).pipe( + Atom.keepAlive, + Atom.withLabel("web-active-environment-id"), +); + +export function useActiveEnvironmentId(): EnvironmentId | null { + return useAtomValue(activeEnvironmentIdAtom); +} + +export function readActiveEnvironmentId(): EnvironmentId | null { + return appAtomRegistry.get(activeEnvironmentIdAtom); +} + +export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { + appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); +} + +export function useProjectRefs(): ReadonlyArray<ScopedProjectRef> { + return useAtomValue(environmentProjects.projectRefsAtom); +} + +export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> { + return useAtomValue(environmentThreadShells.threadRefsAtom); +} + +export function useEnvironmentProjectRefs( + environmentId: EnvironmentId | null, +): ReadonlyArray<ScopedProjectRef> { + return useAtomValue( + environmentId === null + ? EMPTY_PROJECT_REFS_ATOM + : environmentProjects.environmentProjectRefsAtom(environmentId), + ); +} + +export function useEnvironmentThreadRefs( + environmentId: EnvironmentId | null, +): ReadonlyArray<ScopedThreadRef> { + return useAtomValue( + environmentId === null + ? EMPTY_THREAD_REFS_ATOM + : environmentThreadShells.environmentThreadRefsAtom(environmentId), + ); +} + +export function useProjects(): ReadonlyArray<EnvironmentProject> { + return useAtomValue(environmentProjects.projectsAtom); +} + +export function useThreadShells(): ReadonlyArray<EnvironmentThreadShell> { + return useAtomValue(environmentThreadShells.threadShellsAtom); +} + +export function useThreadShellsForProjectRefs( + refs: ReadonlyArray<ScopedProjectRef>, +): ReadonlyArray<EnvironmentThreadShell> { + return useAtomValue(environmentThreadShells.threadShellsForProjectRefsAtom(refs)); +} + +export function useProject(ref: ScopedProjectRef | null): EnvironmentProject | null { + return useAtomValue(ref === null ? EMPTY_PROJECT_ATOM : environmentProjects.projectAtom(ref)); +} + +export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadShell | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_SHELL_ATOM : environmentThreadShells.threadShellAtom(ref), + ); +} + +export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_DETAIL_ATOM : environmentThreadDetails.detailAtom(ref), + ); +} + +export function useThreadMessages( + ref: ScopedThreadRef | null, +): ReadonlyArray<OrchestrationMessage> { + return useAtomValue( + ref === null ? EMPTY_MESSAGES_ATOM : environmentThreadDetails.messagesAtom(ref), + ); +} + +export function useThreadActivities( + ref: ScopedThreadRef | null, +): ReadonlyArray<OrchestrationThreadActivity> { + return useAtomValue( + ref === null ? EMPTY_ACTIVITIES_ATOM : environmentThreadDetails.activitiesAtom(ref), + ); +} + +export function useThreadProposedPlans( + ref: ScopedThreadRef | null, +): ReadonlyArray<OrchestrationProposedPlan> { + return useAtomValue( + ref === null ? EMPTY_PROPOSED_PLANS_ATOM : environmentThreadDetails.proposedPlansAtom(ref), + ); +} + +export function useThreadSession(ref: ScopedThreadRef | null): OrchestrationSession | null { + return useAtomValue( + ref === null ? EMPTY_SESSION_ATOM : environmentThreadDetails.sessionAtom(ref), + ); +} + +export function readProject(ref: ScopedProjectRef): EnvironmentProject | null { + return appAtomRegistry.get(environmentProjects.projectAtom(ref)); +} + +export function readThreadShell(ref: ScopedThreadRef): EnvironmentThreadShell | null { + return appAtomRegistry.get(environmentThreadShells.threadShellAtom(ref)); +} + +export function readThreadDetail(ref: ScopedThreadRef): EnvironmentThread | null { + return appAtomRegistry.get(environmentThreadDetails.detailAtom(ref)); +} + +export function readEnvironmentThreadRefs( + environmentId: EnvironmentId, +): ReadonlyArray<ScopedThreadRef> { + return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); +} + +export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> { + return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); +} + +export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { + return ( + appAtomRegistry + .get(environmentThreadShells.threadRefsAtom) + .find((ref) => ref.threadId === threadId) ?? null + ); +} diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts new file mode 100644 index 00000000000..dea727029bd --- /dev/null +++ b/apps/web/src/state/environments.ts @@ -0,0 +1,172 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + type EnvironmentPresentation as BaseEnvironmentPresentation, +} from "@t3tools/client-runtime/connection"; +import { + RelayConnectionRegistration, + RelayConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { environmentCatalog } from "../connection/catalog"; +import { + connectPairing as connectPairingAtom, + connectSshEnvironment as connectSshEnvironmentAtom, +} from "../connection/onboarding"; +import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; +import { useEnvironmentQuery } from "./query"; +import { relayEnvironmentDiscovery } from "./relay"; +import { usePreparedConnection } from "./session"; + +export interface EnvironmentPresentation extends BaseEnvironmentPresentation { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly displayUrl: string | null; + readonly relayManaged: boolean; +} + +export const primaryEnvironmentIdAtom = Atom.make((get) => { + for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { + if (entry.target._tag === "PrimaryConnectionTarget") { + return environmentId; + } + } + return null; +}).pipe(Atom.withLabel("web-primary-environment-id")); + +function projectEnvironmentPresentation( + environmentId: EnvironmentId, + presentation: BaseEnvironmentPresentation, +): EnvironmentPresentation { + return { + ...presentation, + environmentId, + label: presentation.entry.target.label, + displayUrl: connectionCatalogDisplayUrl(presentation.entry), + relayManaged: presentation.entry.target._tag === "RelayConnectionTarget", + }; +} + +export function useEnvironments() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const networkStatus = useAtomValue(environmentCatalog.networkStatusValueAtom); + const presentationById = useAtomValue(environmentPresentations.presentationsAtom); + + const environments = useMemo( + () => + [...presentationById.entries()].map(([environmentId, presentation]) => + projectEnvironmentPresentation(environmentId, presentation), + ), + [presentationById], + ); + + return { + isReady: catalog.isReady, + networkStatus, + environments, + presentationById, + }; +} + +export function usePrimaryEnvironmentId(): EnvironmentId | null { + return useAtomValue(primaryEnvironmentIdAtom); +} + +export function useEnvironment( + environmentId: EnvironmentId | null, +): EnvironmentPresentation | null { + const { presentation } = useEnvironmentPresentation(environmentId); + return useMemo( + () => + environmentId === null || presentation === null + ? null + : projectEnvironmentPresentation(environmentId, presentation), + [environmentId, presentation], + ); +} + +export function usePrimaryEnvironment(): EnvironmentPresentation | null { + return useEnvironment(usePrimaryEnvironmentId()); +} + +export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): string | null { + const prepared = usePreparedConnection(environmentId); + return Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; +} + +export function useRelayEnvironmentDiscovery() { + return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); +} + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} + +export function useEnvironmentConnectionActions() { + const register = useAtomSet(environmentCatalog.register, { mode: "promise" }); + const remove = useAtomSet(environmentCatalog.remove, { mode: "promise" }); + const removeRelayEnvironments = useAtomSet(environmentCatalog.removeRelayEnvironments, { + mode: "promise", + }); + const retryNow = useAtomSet(environmentCatalog.retryNow, { mode: "promise" }); + + return useMemo( + () => ({ + register, + remove, + removeRelayEnvironments, + retryNow, + }), + [register, remove, removeRelayEnvironments, retryNow], + ); +} + +export function useEnvironmentActions() { + const { register, remove, retryNow } = useEnvironmentConnectionActions(); + const connectPairing = useAtomSet(connectPairingAtom, { + mode: "promise", + }); + const connectSshEnvironment = useAtomSet(connectSshEnvironmentAtom, { + mode: "promise", + }); + const refreshRelayEnvironments = useAtomSet(relayEnvironmentDiscovery.refresh, { + mode: "promise", + }); + + const connectRelayEnvironment = useCallback( + (environment: RelayClientEnvironmentRecord) => + register( + new RelayConnectionRegistration({ + target: new RelayConnectionTarget({ + environmentId: environment.environmentId, + label: environment.label, + }), + }), + ), + [register], + ); + + return useMemo( + () => ({ + connectPairing, + connectSshEnvironment, + connectRelayEnvironment, + removeEnvironment: remove, + retryEnvironment: retryNow, + refreshRelayEnvironments, + }), + [ + connectPairing, + connectRelayEnvironment, + connectSshEnvironment, + refreshRelayEnvironments, + remove, + retryNow, + ], + ); +} diff --git a/apps/web/src/state/filesystem.ts b/apps/web/src/state/filesystem.ts new file mode 100644 index 00000000000..19d5b53c4e0 --- /dev/null +++ b/apps/web/src/state/filesystem.ts @@ -0,0 +1,5 @@ +import { createFilesystemEnvironmentAtoms } from "@t3tools/client-runtime/state/filesystem"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const filesystemEnvironment = createFilesystemEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/git.ts b/apps/web/src/state/git.ts new file mode 100644 index 00000000000..66bb3dc0bde --- /dev/null +++ b/apps/web/src/state/git.ts @@ -0,0 +1,5 @@ +import { createGitEnvironmentAtoms } from "@t3tools/client-runtime/state/git"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const gitEnvironment = createGitEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/orchestration.ts b/apps/web/src/state/orchestration.ts new file mode 100644 index 00000000000..8c6e1738857 --- /dev/null +++ b/apps/web/src/state/orchestration.ts @@ -0,0 +1,5 @@ +import { createOrchestrationEnvironmentAtoms } from "@t3tools/client-runtime/state/orchestration"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const orchestrationEnvironment = createOrchestrationEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/presentation.ts b/apps/web/src/state/presentation.ts new file mode 100644 index 00000000000..0a4cfd12556 --- /dev/null +++ b/apps/web/src/state/presentation.ts @@ -0,0 +1,31 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentPresentation } from "@t3tools/client-runtime/connection"; +import { createEnvironmentPresentationAtoms } from "@t3tools/client-runtime/state/presentation"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { environmentSession } from "./session"; + +export const environmentPresentations = createEnvironmentPresentationAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + stateAtom: environmentCatalog.stateAtom, + configValueAtom: environmentSession.configValueAtom, +}); + +const EMPTY_ENVIRONMENT_PRESENTATION_ATOM = Atom.make<EnvironmentPresentation | null>(null).pipe( + Atom.withLabel("web-environment-presentation:empty"), +); + +export function useEnvironmentPresentation(environmentId: EnvironmentId | null) { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const presentation = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_PRESENTATION_ATOM + : environmentPresentations.presentationAtom(environmentId), + ); + return { + isReady: catalog.isReady, + presentation, + }; +} diff --git a/apps/web/src/state/preview.ts b/apps/web/src/state/preview.ts new file mode 100644 index 00000000000..5d46b7dd4cd --- /dev/null +++ b/apps/web/src/state/preview.ts @@ -0,0 +1,47 @@ +import { useAtomSet } from "@effect/atom-react"; +import { createPreviewEnvironmentAtoms } from "@t3tools/client-runtime/state/preview"; +import { useMemo } from "react"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const previewEnvironment = createPreviewEnvironmentAtoms(connectionAtomRuntime); + +export function usePreviewActions() { + const open = useAtomSet(previewEnvironment.open, { mode: "promise" }); + const navigate = useAtomSet(previewEnvironment.navigate, { mode: "promise" }); + const refresh = useAtomSet(previewEnvironment.refresh, { mode: "promise" }); + const close = useAtomSet(previewEnvironment.close, { mode: "promise" }); + const reportStatus = useAtomSet(previewEnvironment.reportStatus, { mode: "promise" }); + const respondToAutomation = useAtomSet(previewEnvironment.respondToAutomation, { + mode: "promise", + }); + const reportAutomationOwner = useAtomSet(previewEnvironment.reportAutomationOwner, { + mode: "promise", + }); + const clearAutomationOwner = useAtomSet(previewEnvironment.clearAutomationOwner, { + mode: "promise", + }); + + return useMemo( + () => ({ + open, + navigate, + refresh, + close, + reportStatus, + respondToAutomation, + reportAutomationOwner, + clearAutomationOwner, + }), + [ + clearAutomationOwner, + close, + navigate, + open, + refresh, + reportAutomationOwner, + reportStatus, + respondToAutomation, + ], + ); +} diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts new file mode 100644 index 00000000000..7a879988328 --- /dev/null +++ b/apps/web/src/state/projects.ts @@ -0,0 +1,12 @@ +import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; +import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +export const environmentProjects = createEnvironmentProjectAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts new file mode 100644 index 00000000000..79737e6109e --- /dev/null +++ b/apps/web/src/state/queries.ts @@ -0,0 +1,257 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + type CheckpointDiffTarget, + type ComposerPathSearchTarget, +} from "@t3tools/client-runtime/state/threads"; +import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; +import type { + EnvironmentId, + OrchestrationThread, + ThreadId, + VcsListRefsResult, + VcsRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { orchestrationEnvironment } from "./orchestration"; +import { projectEnvironment } from "./projects"; +import { useEnvironmentQuery } from "./query"; +import { useEnvironmentThread } from "./threads"; +import { vcsEnvironment } from "./vcs"; + +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; +const COMPOSER_PATH_SEARCH_LIMIT = 80; +const VCS_REF_LIST_LIMIT = 100; +const EMPTY_REFS: ReadonlyArray<VcsRef> = []; +const INITIAL_BRANCH_CURSORS = [undefined] as const; + +export interface ThreadDetailView { + readonly data: OrchestrationThread | null; + readonly error: string | null; + readonly isPending: boolean; + readonly isDeleted: boolean; +} + +function useDebouncedValue<A>(value: A, delayMs: number): A { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebounced(value); + }, delayMs); + return () => { + window.clearTimeout(timer); + }; + }, [delayMs, value]); + + return debounced; +} + +export function useThreadDetail( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): ThreadDetailView { + const state = useEnvironmentThread(environmentId, threadId); + return { + data: Option.getOrNull(state.data), + error: Option.getOrNull(state.error), + isPending: state.status === "synchronizing", + isDeleted: state.status === "deleted", + }; +} + +export function useBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + return useEnvironmentQuery( + target.environmentId !== null && target.cwd !== null + ? vcsEnvironment.listRefs({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + ...(query.length > 0 ? { query } : {}), + limit: VCS_REF_LIST_LIMIT, + }, + }) + : null, + ); +} + +export function usePaginatedBranches(target: VcsRefTarget) { + const query = target.query?.trim() ?? ""; + const targetKey = + target.environmentId !== null && target.cwd !== null + ? JSON.stringify([target.environmentId, target.cwd, query]) + : null; + const [pagination, setPagination] = useState<{ + readonly targetKey: string | null; + readonly cursors: ReadonlyArray<number | undefined>; + }>({ + targetKey, + cursors: INITIAL_BRANCH_CURSORS, + }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listRefs({ + environmentId: target.environmentId!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit: VCS_REF_LIST_LIMIT, + }, + }), + ) + : [], + [cursors, query, target.cwd, target.environmentId], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`web:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const values = results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }); + const refs = new Map<string, VcsRef>(); + for (const value of values) { + for (const ref of value.refs) { + refs.set(ref.name, ref); + } + } + const first = values[0] ?? null; + const last = values.at(-1) ?? null; + const data: VcsListRefsResult | null = + first === null || last === null + ? null + : { + refs: [...refs.values()], + isRepo: first.isRepo, + hasPrimaryRemote: first.hasPrimaryRemote, + nextCursor: last.nextCursor, + totalCount: Math.max(...values.map((value) => value.totalCount)), + }; + const failed = results.find((result) => result._tag === "Failure"); + const error = + failed?._tag === "Failure" + ? (() => { + const cause = Cause.squash(failed.cause); + return cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load refs."; + })() + : null; + const refresh = useCallback(() => { + const firstPage = pageAtoms[0]; + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + if (firstPage !== undefined) { + appAtomRegistry.refresh(firstPage); + } + }, [pageAtoms, targetKey]); + const loadNext = useCallback(() => { + if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { + return; + } + setPagination((current) => { + const currentCursors = + current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; + return currentCursors.includes(data.nextCursor!) + ? { targetKey, cursors: currentCursors } + : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; + }); + }, [data?.nextCursor, targetKey]); + + return { + data, + refs: data?.refs ?? EMPTY_REFS, + error, + isPending: results.some((result) => result.waiting), + refresh, + loadNext, + }; +} + +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + const normalizedTarget = useMemo( + () => ({ + environmentId: target.environmentId, + cwd: target.cwd, + query: target.query?.trim() ?? "", + }), + [target.cwd, target.environmentId, target.query], + ); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + debouncedTarget.environmentId !== null && + debouncedTarget.cwd !== null && + debouncedTarget.query.length > 0 + ? projectEnvironment.searchEntries({ + environmentId: debouncedTarget.environmentId, + input: { + cwd: debouncedTarget.cwd, + query: debouncedTarget.query, + limit: COMPOSER_PATH_SEARCH_LIMIT, + }, + }) + : null, + ); + + return { + entries: result.data?.entries ?? [], + error: result.error, + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + refresh: result.refresh, + }; +} + +export function useCheckpointDiff( + target: CheckpointDiffTarget, + options?: { readonly enabled?: boolean }, +) { + const enabled = + options?.enabled !== false && + target.environmentId !== null && + target.threadId !== null && + target.fromTurnCount !== null && + target.toTurnCount !== null; + const fullThreadTarget = + enabled && target.fromTurnCount === 0 + ? { + environmentId: target.environmentId!, + input: { + threadId: target.threadId!, + toTurnCount: target.toTurnCount!, + ignoreWhitespace: target.ignoreWhitespace, + }, + } + : null; + const turnTarget = + enabled && target.fromTurnCount !== 0 + ? { + environmentId: target.environmentId!, + input: { + threadId: target.threadId!, + fromTurnCount: target.fromTurnCount!, + toTurnCount: target.toTurnCount!, + ignoreWhitespace: target.ignoreWhitespace, + }, + } + : null; + const fullThread = useEnvironmentQuery( + fullThreadTarget === null ? null : orchestrationEnvironment.fullThreadDiff(fullThreadTarget), + ); + const turn = useEnvironmentQuery( + turnTarget === null ? null : orchestrationEnvironment.turnDiff(turnTarget), + ); + return fullThreadTarget === null ? turn : fullThread; +} diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts new file mode 100644 index 00000000000..2610f1724a0 --- /dev/null +++ b/apps/web/src/state/query.ts @@ -0,0 +1,36 @@ +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial<never, never>(false)).pipe( + Atom.withLabel("web-environment-query:empty"), +); + +export interface EnvironmentQueryView<A> { + readonly data: A | null; + readonly error: string | null; + readonly isPending: boolean; + readonly refresh: () => void; +} + +function formatError(cause: Cause.Cause<unknown>): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The environment request failed."; +} + +export function useEnvironmentQuery<A, E>( + atom: Atom.Atom<AsyncResult.AsyncResult<A, E>> | null, +): EnvironmentQueryView<A> { + const selectedAtom = atom ?? EMPTY_ASYNC_RESULT_ATOM; + const result = useAtomValue(selectedAtom); + const refresh = useAtomRefresh(selectedAtom); + return { + data: Option.getOrNull(AsyncResult.value(result)), + error: result._tag === "Failure" ? formatError(result.cause) : null, + isPending: atom !== null && result.waiting, + refresh, + }; +} diff --git a/apps/web/src/state/relay.ts b/apps/web/src/state/relay.ts new file mode 100644 index 00000000000..f078572736b --- /dev/null +++ b/apps/web/src/state/relay.ts @@ -0,0 +1,6 @@ +import { createRelayEnvironmentDiscoveryAtoms } from "@t3tools/client-runtime/state/relay"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const relayEnvironmentDiscovery = + createRelayEnvironmentDiscoveryAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/review.ts b/apps/web/src/state/review.ts new file mode 100644 index 00000000000..e4289d1f1d5 --- /dev/null +++ b/apps/web/src/state/review.ts @@ -0,0 +1,5 @@ +import { createReviewEnvironmentAtoms } from "@t3tools/client-runtime/state/review"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const reviewEnvironment = createReviewEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts new file mode 100644 index 00000000000..c6bb1037338 --- /dev/null +++ b/apps/web/src/state/server.ts @@ -0,0 +1,99 @@ +import { + DEFAULT_SERVER_SETTINGS, + type EditorId, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleWelcomePayload, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; +import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { primaryEnvironmentIdAtom } from "./environments"; +import { environmentSession } from "./session"; + +export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime); +export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + configValueAtom: environmentSession.configValueAtom, +}); + +interface PrimaryServerState { + readonly config: ServerConfig | null; + readonly latestEvent: ServerConfigStreamEvent | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +const EMPTY_AVAILABLE_EDITORS: ReadonlyArray<EditorId> = []; +const EMPTY_SERVER_PROVIDERS: ReadonlyArray<ServerProvider> = []; +const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { + config: null, + latestEvent: null, + welcome: null, +}; + +export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { + const environmentId = get(primaryEnvironmentIdAtom); + if (environmentId === null) { + return EMPTY_PRIMARY_SERVER_STATE; + } + + const target = { environmentId, input: {} }; + const configProjection = Option.getOrNull( + AsyncResult.value(get(serverEnvironment.configProjection(target))), + ); + const queriedConfig = Option.getOrNull(AsyncResult.value(get(serverEnvironment.config(target)))); + const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target)))); + + return { + config: configProjection?.config ?? queriedConfig, + latestEvent: configProjection?.latestEvent ?? null, + welcome, + }; +}).pipe(Atom.withLabel("web-primary-server-state")); + +export const primaryServerConfigAtom = Atom.make( + (get): ServerConfig | null => get(primaryServerStateAtom).config, +).pipe(Atom.withLabel("web-primary-server-config")); + +export const primaryServerConfigEventAtom = Atom.make( + (get): ServerConfigStreamEvent | null => get(primaryServerStateAtom).latestEvent, +).pipe(Atom.withLabel("web-primary-server-config-event")); + +export const primaryServerWelcomeAtom = Atom.make( + (get): ServerLifecycleWelcomePayload | null => get(primaryServerStateAtom).welcome, +).pipe(Atom.withLabel("web-primary-server-welcome")); + +export const primaryServerSettingsAtom = Atom.make( + (get): ServerSettings => get(primaryServerConfigAtom)?.settings ?? DEFAULT_SERVER_SETTINGS, +).pipe(Atom.withLabel("web-primary-server-settings")); + +export const primaryServerProvidersAtom = Atom.make( + (get): ReadonlyArray<ServerProvider> => + get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, +).pipe(Atom.withLabel("web-primary-server-providers")); + +export const primaryServerKeybindingsAtom = Atom.make( + (get): ServerConfig["keybindings"] => + get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, +).pipe(Atom.withLabel("web-primary-server-keybindings")); + +export const primaryServerAvailableEditorsAtom = Atom.make( + (get): ReadonlyArray<EditorId> => + get(primaryServerConfigAtom)?.availableEditors ?? EMPTY_AVAILABLE_EDITORS, +).pipe(Atom.withLabel("web-primary-server-available-editors")); + +export const primaryServerKeybindingsConfigPathAtom = Atom.make( + (get): string | null => get(primaryServerConfigAtom)?.keybindingsConfigPath ?? null, +).pipe(Atom.withLabel("web-primary-server-keybindings-config-path")); + +export const primaryServerObservabilityAtom = Atom.make( + (get): ServerConfig["observability"] | null => + get(primaryServerConfigAtom)?.observability ?? null, +).pipe(Atom.withLabel("web-primary-server-observability")); diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts new file mode 100644 index 00000000000..5f27fe6f66c --- /dev/null +++ b/apps/web/src/state/session.ts @@ -0,0 +1,33 @@ +import { useAtomValue } from "@effect/atom-react"; +import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { Atom } from "effect/unstable/reactivity"; + +import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useEnvironmentQuery } from "./query"; + +export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRuntime); + +const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( + Atom.withLabel("web-prepared-connection:empty"), +); + +export function useEnvironmentConfig(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentSession.configAtom(environmentId)); +} + +export function usePreparedConnection(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_PREPARED_CONNECTION_ATOM + : environmentSession.preparedConnectionValueAtom(environmentId), + ); +} + +export function readPreparedConnection(environmentId: EnvironmentId) { + return Option.getOrNull( + appAtomRegistry.get(environmentSession.preparedConnectionValueAtom(environmentId)), + ); +} diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts new file mode 100644 index 00000000000..e879dd25e29 --- /dev/null +++ b/apps/web/src/state/shell.ts @@ -0,0 +1,17 @@ +import { + createEnvironmentShellAtoms, + createEnvironmentShellSummaryAtom, + createEnvironmentSnapshotAtom, + createShellEnvironmentAtoms, +} from "@t3tools/client-runtime/state/shell"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); +export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); +export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); +export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + shellStateValueAtom: environmentShell.stateValueAtom, +}); diff --git a/apps/web/src/state/sourceControl.ts b/apps/web/src/state/sourceControl.ts new file mode 100644 index 00000000000..aa6255f85ff --- /dev/null +++ b/apps/web/src/state/sourceControl.ts @@ -0,0 +1,5 @@ +import { createSourceControlEnvironmentAtoms } from "@t3tools/client-runtime/state/source-control"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const sourceControlEnvironment = createSourceControlEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts new file mode 100644 index 00000000000..14fe2d324cf --- /dev/null +++ b/apps/web/src/state/sourceControlActions.ts @@ -0,0 +1,365 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import type { + EnvironmentId, + GitActionProgressEvent, + GitResolvePullRequestResult, + GitRunStackedActionResult, + GitStackedAction, + SourceControlCloneProtocol, + SourceControlPublishRepositoryResult, + SourceControlRepositoryVisibility, + ThreadId, + VcsPullResult, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + useTransition, +} from "react"; + +import { gitEnvironment } from "./git"; +import { useEnvironmentQuery } from "./query"; +import { sourceControlEnvironment } from "./sourceControl"; +import { vcsEnvironment } from "./vcs"; + +export type SourceControlActionKind = + | "init" + | "pull" + | "publishRepository" + | "runStackedAction" + | "preparePullRequestThread"; + +export interface SourceControlActionScope { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} + +interface SourceControlActionState<TArgs extends ReadonlyArray<unknown>, TResult> { + readonly isPending: boolean; + readonly error: unknown; + readonly run: (...args: TArgs) => Promise<TResult>; + readonly resetError: () => void; +} + +const actionListeners = new Set<() => void>(); +const activeActionCounts = new Map<string, number>(); +const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>(); + +function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string { + return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`; +} + +function notifyActionListeners(): void { + for (const listener of actionListeners) { + listener(); + } +} + +function beginAction(key: string): () => void { + activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1); + notifyActionListeners(); + let completed = false; + return () => { + if (completed) { + return; + } + completed = true; + const next = (activeActionCounts.get(key) ?? 1) - 1; + if (next <= 0) { + activeActionCounts.delete(key); + } else { + activeActionCounts.set(key, next); + } + notifyActionListeners(); + }; +} + +function useAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: { + readonly kind: SourceControlActionKind; + readonly scope: SourceControlActionScope; + readonly action: (...args: TArgs) => Promise<TResult>; + readonly onSuccess?: () => void; +}): SourceControlActionState<TArgs, TResult> { + const [error, setError] = useState<unknown>(null); + const [activeCount, setActiveCount] = useState(0); + const [isTransitionPending, startTransition] = useTransition(); + const key = actionKey(input.kind, input.scope); + + const resetError = useCallback(() => { + startTransition(() => setError(null)); + }, []); + + const run = useCallback( + async (...args: TArgs): Promise<TResult> => { + const complete = beginAction(key); + startTransition(() => { + setError(null); + setActiveCount((count) => count + 1); + }); + try { + const result = await input.action(...args); + input.onSuccess?.(); + return result; + } catch (cause) { + startTransition(() => setError(cause)); + throw cause; + } finally { + complete(); + startTransition(() => setActiveCount((count) => Math.max(0, count - 1))); + } + }, + [input.action, input.onSuccess, key], + ); + + return { + error, + isPending: activeCount > 0 || isTransitionPending, + resetError, + run, + }; +} + +function requireScope(scope: SourceControlActionScope, unavailableMessage: string) { + if (scope.environmentId === null || scope.cwd === null) { + throw new Error(unavailableMessage); + } + return { + environmentId: scope.environmentId, + cwd: scope.cwd, + }; +} + +export function useSourceControlActionRunning( + scope: SourceControlActionScope, + kinds: ReadonlyArray<SourceControlActionKind>, +): boolean { + const stableKinds = useMemo(() => kinds.toSorted(), [kinds]); + return useSyncExternalStore( + (listener) => { + actionListeners.add(listener); + return () => actionListeners.delete(listener); + }, + () => stableKinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0), + () => false, + ); +} + +export function useVcsInitAction(scope: SourceControlActionScope) { + const init = useAtomSet(vcsEnvironment.init, { mode: "promise" }); + const action = useCallback(async () => { + const target = requireScope(scope, "Git init is unavailable."); + return init({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [init, scope]); + return useAction({ kind: "init", scope, action }); +} + +export function useVcsPullAction(scope: SourceControlActionScope) { + const pull = useAtomSet(vcsEnvironment.pull, { mode: "promise" }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback(async (): Promise<VcsPullResult> => { + const target = requireScope(scope, "Git pull is unavailable."); + return pull({ + environmentId: target.environmentId, + input: { cwd: target.cwd }, + }); + }, [pull, scope]); + return useAction({ + kind: "pull", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function useGitStackedAction(scope: SourceControlActionScope) { + const runStackedAction = useAtomSet(gitEnvironment.runStackedAction, { + mode: "promise", + }); + const progress = useAtomValue(gitEnvironment.runStackedAction); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const progressListenerRef = useRef<((event: GitActionProgressEvent) => void) | null>(null); + + useEffect(() => { + const event = Option.getOrNull(AsyncResult.value(progress)); + if (event !== null && event.cwd === scope.cwd) { + progressListenerRef.current?.(event); + } + }, [progress, progressListenerRef, scope.cwd]); + + const action = useCallback( + async (input: { + actionId: string; + action: GitStackedAction; + commitMessage?: string; + featureBranch?: boolean; + filePaths?: string[]; + onProgress?: (event: GitActionProgressEvent) => void; + }): Promise<GitRunStackedActionResult> => { + const target = requireScope(scope, "Git action is unavailable."); + progressListenerRef.current = input.onProgress ?? null; + try { + const event = await runStackedAction({ + environmentId: target.environmentId, + input: { + actionId: input.actionId, + cwd: target.cwd, + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: true } : {}), + ...(input.filePaths?.length ? { filePaths: input.filePaths } : {}), + }, + }); + if (event.kind === "action_failed") { + throw new Error(event.message); + } + if (event.kind !== "action_finished") { + throw new Error("Source control action ended without a result."); + } + return event.result; + } finally { + progressListenerRef.current = null; + } + }, + [progressListenerRef, runStackedAction, scope], + ); + + return useAction({ + kind: "runStackedAction", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { + const publishRepository = useAtomSet(sourceControlEnvironment.publishRepository, { + mode: "promise", + }); + const status = useEnvironmentQuery( + scope.environmentId !== null && scope.cwd !== null + ? vcsEnvironment.status({ + environmentId: scope.environmentId, + input: { cwd: scope.cwd }, + }) + : null, + ); + const action = useCallback( + async (input: { + provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; + repository: string; + visibility: SourceControlRepositoryVisibility; + remoteName: string; + protocol: SourceControlCloneProtocol; + }): Promise<SourceControlPublishRepositoryResult> => { + const target = requireScope(scope, "Repository publishing is unavailable."); + return publishRepository({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + ...input, + }, + }); + }, + [publishRepository, scope], + ); + return useAction({ + kind: "publishRepository", + scope, + action, + onSuccess: status.refresh, + }); +} + +export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) { + const preparePullRequestThread = useAtomSet(gitEnvironment.preparePullRequestThread, { + mode: "promise", + }); + const action = useCallback( + async (input: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => { + const target = requireScope(scope, "Pull request thread preparation is unavailable."); + return preparePullRequestThread({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + reference: input.reference, + mode: input.mode, + ...(input.threadId ? { threadId: input.threadId } : {}), + }, + }); + }, + [preparePullRequestThread, scope], + ); + return useAction({ kind: "preparePullRequestThread", scope, action }); +} + +export interface PullRequestResolutionTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly reference: string | null; +} + +function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null { + if (target.environmentId === null || target.cwd === null || target.reference === null) { + return null; + } + return `${target.environmentId}:${target.cwd}:${target.reference}`; +} + +export function readCachedPullRequestResolution( + target: PullRequestResolutionTarget, +): GitResolvePullRequestResult | null { + const key = pullRequestResolutionKey(target); + return key === null ? null : (pullRequestResolutionCache.get(key) ?? null); +} + +export function usePullRequestResolutionState(target: PullRequestResolutionTarget) { + const query = useEnvironmentQuery( + target.environmentId !== null && target.cwd !== null && target.reference !== null + ? gitEnvironment.pullRequestResolution({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + reference: target.reference, + }, + }) + : null, + ); + const key = pullRequestResolutionKey(target); + + useEffect(() => { + if (key !== null && query.data !== null) { + pullRequestResolutionCache.set(key, query.data); + } + }, [key, query.data]); + + return { + data: query.data ?? readCachedPullRequestResolution(target), + error: query.error, + isPending: query.isPending && readCachedPullRequestResolution(target) === null, + isFetching: query.isPending, + refresh: query.refresh, + }; +} diff --git a/apps/web/src/state/terminal.ts b/apps/web/src/state/terminal.ts new file mode 100644 index 00000000000..920267c33d5 --- /dev/null +++ b/apps/web/src/state/terminal.ts @@ -0,0 +1,5 @@ +import { createTerminalEnvironmentAtoms } from "@t3tools/client-runtime/state/terminal"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const terminalEnvironment = createTerminalEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts new file mode 100644 index 00000000000..d5525f5406f --- /dev/null +++ b/apps/web/src/state/terminalSessions.ts @@ -0,0 +1,182 @@ +import { + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, + EMPTY_TERMINAL_SESSION_STATE, + type KnownTerminalSession, + type TerminalSessionState, +} from "@t3tools/client-runtime/state/terminal"; +import { ThreadId, type EnvironmentId, type TerminalAttachInput } from "@t3tools/contracts"; +import { useAtomSet } from "@effect/atom-react"; +import { useCallback, useMemo } from "react"; + +import { useEnvironmentQuery } from "./query"; +import { terminalEnvironment } from "./terminal"; + +export function useAttachedTerminalSession(input: { + readonly environmentId: EnvironmentId | null; + readonly terminal: TerminalAttachInput | null; +}): TerminalSessionState { + const attach = useEnvironmentQuery( + input.environmentId !== null && input.terminal !== null + ? terminalEnvironment.attach({ + environmentId: input.environmentId, + input: input.terminal, + }) + : null, + ); + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), + ); + + return useMemo(() => { + if (input.environmentId === null || input.terminal === null) { + return EMPTY_TERMINAL_SESSION_STATE; + } + const summary = + metadata.data?.find( + (terminal) => + terminal.threadId === input.terminal?.threadId && + terminal.terminalId === input.terminal?.terminalId, + ) ?? null; + const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; + }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); +} + +export function useKnownTerminalSessions(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray<KnownTerminalSession> { + const metadata = useEnvironmentQuery( + input.environmentId === null + ? null + : terminalEnvironment.metadata({ + environmentId: input.environmentId, + input: null, + }), + ); + return useMemo(() => { + if (input.environmentId === null) { + return []; + } + return (metadata.data ?? []) + .filter((summary) => input.threadId === null || summary.threadId === input.threadId) + .map((summary) => ({ + target: { + environmentId: input.environmentId!, + threadId: ThreadId.make(summary.threadId), + terminalId: summary.terminalId, + }, + state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), + })) + .sort((left, right) => + left.target.terminalId.localeCompare(right.target.terminalId, undefined, { + numeric: true, + }), + ); + }, [input.environmentId, input.threadId, metadata.data]); +} + +export function useThreadRunningTerminalIds(input: { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; +}): ReadonlyArray<string> { + return useKnownTerminalSessions(input) + .filter((session) => session.state.status === "running") + .map((session) => session.target.terminalId); +} + +export function useTerminalController(input: { + readonly environmentId: EnvironmentId; + readonly terminal: TerminalAttachInput; +}) { + const writeTerminal = useAtomSet(terminalEnvironment.write, { mode: "promise" }); + const resizeTerminal = useAtomSet(terminalEnvironment.resize, { mode: "promise" }); + const clearTerminal = useAtomSet(terminalEnvironment.clear, { mode: "promise" }); + const restartTerminal = useAtomSet(terminalEnvironment.restart, { mode: "promise" }); + const closeTerminal = useAtomSet(terminalEnvironment.close, { mode: "promise" }); + const session = useAttachedTerminalSession(input); + const { environmentId, terminal } = input; + + const write = useCallback( + (data: string) => + writeTerminal({ + environmentId, + input: { + threadId: terminal.threadId, + terminalId: terminal.terminalId, + data, + }, + }), + [environmentId, terminal.terminalId, terminal.threadId, writeTerminal], + ); + const resize = useCallback( + (cols: number, rows: number) => + resizeTerminal({ + environmentId, + input: { + threadId: terminal.threadId, + terminalId: terminal.terminalId, + cols, + rows, + }, + }), + [environmentId, resizeTerminal, terminal.terminalId, terminal.threadId], + ); + const clear = useCallback( + () => + clearTerminal({ + environmentId, + input: { + threadId: terminal.threadId, + terminalId: terminal.terminalId, + }, + }), + [clearTerminal, environmentId, terminal.terminalId, terminal.threadId], + ); + const restart = useCallback(() => { + if (terminal.cwd === undefined || terminal.cols === undefined || terminal.rows === undefined) { + return Promise.reject( + new Error("Terminal restart requires the working directory and dimensions."), + ); + } + return restartTerminal({ + environmentId, + input: { + threadId: terminal.threadId, + terminalId: terminal.terminalId, + cwd: terminal.cwd, + cols: terminal.cols, + rows: terminal.rows, + ...(terminal.worktreePath !== undefined ? { worktreePath: terminal.worktreePath } : {}), + ...(terminal.env !== undefined ? { env: terminal.env } : {}), + }, + }); + }, [environmentId, restartTerminal, terminal]); + const close = useCallback( + (options?: { readonly deleteHistory?: boolean }) => + closeTerminal({ + environmentId, + input: { + threadId: terminal.threadId, + terminalId: terminal.terminalId, + ...(options?.deleteHistory ? { deleteHistory: true } : {}), + }, + }), + [closeTerminal, environmentId, terminal.terminalId, terminal.threadId], + ); + + return { + session, + write, + resize, + clear, + restart, + close, + }; +} diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts new file mode 100644 index 00000000000..fd936f99ff2 --- /dev/null +++ b/apps/web/src/state/threads.ts @@ -0,0 +1,45 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + createEnvironmentThreadDetailAtoms, + createEnvironmentThreadShellAtoms, + createEnvironmentThreadStateAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, + createThreadEnvironmentAtoms, +} from "@t3tools/client-runtime/state/threads"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; +import { environmentSnapshotAtom } from "./shell"; + +export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( + environmentThreads.stateAtom, +); +export const environmentThreadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom: environmentCatalog.catalogValueAtom, + snapshotAtom: environmentSnapshotAtom, +}); + +const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( + Atom.withLabel("web-environment-thread:empty"), +); + +export function useEnvironmentThread( + environmentId: EnvironmentId | null, + threadId: ThreadId | null, +): EnvironmentThreadState { + const result = useAtomValue( + environmentId !== null && threadId !== null + ? environmentThreads.stateAtom(environmentId, threadId) + : EMPTY_THREAD_STATE_ATOM, + ); + return Option.getOrElse( + AsyncResult.value(result), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ) as EnvironmentThreadState; +} diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts new file mode 100644 index 00000000000..af18fe0bd91 --- /dev/null +++ b/apps/web/src/state/vcs.ts @@ -0,0 +1,5 @@ +import { createVcsEnvironmentAtoms } from "@t3tools/client-runtime/state/vcs"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts deleted file mode 100644 index 2fe06d518d2..00000000000 --- a/apps/web/src/store.test.ts +++ /dev/null @@ -1,1083 +0,0 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; -import { - CheckpointRef, - DEFAULT_MODEL, - EnvironmentId, - EventId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationEvent, -} from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - applyOrchestrationEvent, - applyOrchestrationEvents, - removeEnvironmentState, - selectEnvironmentState, - selectProjectsAcrossEnvironments, - selectThreadByRef, - selectThreadExistsByRef, - setThreadBranch, - selectThreadsAcrossEnvironments, - type AppState, - type EnvironmentState, -} from "./store"; -import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "./types"; - -const localEnvironmentId = EnvironmentId.make("environment-local"); -const remoteEnvironmentId = EnvironmentId.make("environment-remote"); - -function withActiveEnvironmentState( - environmentState: EnvironmentState, - overrides: Partial<AppState & EnvironmentState> = {}, -): AppState { - const { - activeEnvironmentId: overrideActiveEnvironmentId, - environmentStateById: overrideEnvironmentStateById, - ...environmentOverrides - } = overrides; - const activeEnvironmentId = overrideActiveEnvironmentId ?? localEnvironmentId; - const mergedEnvironmentState = { - ...environmentState, - ...environmentOverrides, - }; - const environmentStateById = - overrideEnvironmentStateById ?? - (activeEnvironmentId - ? { - [activeEnvironmentId]: mergedEnvironmentState, - } - : {}); - - return { - activeEnvironmentId, - environmentStateById, - }; -} - -function makeThread(overrides: Partial<Thread> = {}): Thread { - return { - id: ThreadId.make("thread-1"), - environmentId: localEnvironmentId, - codexThreadId: null, - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - session: null, - messages: [], - turnDiffSummaries: [], - activities: [], - proposedPlans: [], - error: null, - createdAt: "2026-02-13T00:00:00.000Z", - archivedAt: null, - latestTurn: null, - branch: null, - worktreePath: null, - ...overrides, - }; -} - -function makeState(thread: Thread): AppState { - const projectId = ProjectId.make("project-1"); - const project = { - id: projectId, - environmentId: thread.environmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5-codex", - }, - createdAt: "2026-02-13T00:00:00.000Z", - updatedAt: "2026-02-13T00:00:00.000Z", - scripts: [], - }; - const threadIdsByProjectId: EnvironmentState["threadIdsByProjectId"] = { - [thread.projectId]: [thread.id], - }; - const environmentState = { - projectIds: [projectId], - projectById: { - [projectId]: project, - }, - threadIds: [thread.id], - threadIdsByProjectId, - threadShellById: { - [thread.id]: { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - }, - }, - threadSessionById: { - [thread.id]: thread.session, - }, - threadTurnStateById: { - [thread.id]: { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }, - }, - messageIdsByThreadId: { - [thread.id]: thread.messages.map((message) => message.id), - }, - messageByThreadId: { - [thread.id]: Object.fromEntries( - thread.messages.map((message) => [message.id, message] as const), - ) as EnvironmentState["messageByThreadId"][ThreadId], - }, - activityIdsByThreadId: { - [thread.id]: thread.activities.map((activity) => activity.id), - }, - activityByThreadId: { - [thread.id]: Object.fromEntries( - thread.activities.map((activity) => [activity.id, activity] as const), - ) as EnvironmentState["activityByThreadId"][ThreadId], - }, - proposedPlanIdsByThreadId: { - [thread.id]: thread.proposedPlans.map((plan) => plan.id), - }, - proposedPlanByThreadId: { - [thread.id]: Object.fromEntries( - thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as EnvironmentState["proposedPlanByThreadId"][ThreadId], - }, - turnDiffIdsByThreadId: { - [thread.id]: thread.turnDiffSummaries.map((summary) => summary.turnId), - }, - turnDiffSummaryByThreadId: { - [thread.id]: Object.fromEntries( - thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as EnvironmentState["turnDiffSummaryByThreadId"][ThreadId], - }, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - return withActiveEnvironmentState(environmentState, { - activeEnvironmentId: thread.environmentId, - }); -} - -function makeEmptyState(overrides: Partial<AppState & EnvironmentState> = {}): AppState { - const environmentState: EnvironmentState = { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: true, - }; - return withActiveEnvironmentState(environmentState, overrides); -} - -function localEnvironmentStateOf(state: AppState): EnvironmentState { - return selectEnvironmentState(state, localEnvironmentId); -} - -function environmentStateOf(state: AppState, environmentId: EnvironmentId): EnvironmentState { - return selectEnvironmentState(state, environmentId); -} - -function projectsOf(state: AppState) { - return selectProjectsAcrossEnvironments(state); -} - -function threadsOf(state: AppState) { - return selectThreadsAcrossEnvironments(state); -} - -function makeEvent<T extends OrchestrationEvent["type"]>( - type: T, - payload: Extract<OrchestrationEvent, { type: T }>["payload"], - overrides: Partial<Extract<OrchestrationEvent, { type: T }>> = {}, -): Extract<OrchestrationEvent, { type: T }> { - const sequence = overrides.sequence ?? 1; - return { - sequence, - eventId: EventId.make(`event-${sequence}`), - aggregateKind: "thread", - aggregateId: - "threadId" in payload - ? payload.threadId - : "projectId" in payload - ? payload.projectId - : ProjectId.make("project-1"), - occurredAt: "2026-02-27T00:00:00.000Z", - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - type, - payload, - ...overrides, - } as Extract<OrchestrationEvent, { type: T }>; -} - -describe("environment state removal", () => { - it("drops local state for removed environments", () => { - const removedThread = makeThread({ - environmentId: remoteEnvironmentId, - id: ThreadId.make("thread-removed"), - }); - const keptThread = makeThread({ id: ThreadId.make("thread-kept") }); - const removedState = makeState(removedThread).environmentStateById[remoteEnvironmentId]!; - const keptState = makeState(keptThread).environmentStateById[localEnvironmentId]!; - const state: AppState = { - activeEnvironmentId: remoteEnvironmentId, - environmentStateById: { - [remoteEnvironmentId]: removedState, - [localEnvironmentId]: keptState, - }, - }; - - const next = removeEnvironmentState(state, remoteEnvironmentId); - - expect(next.activeEnvironmentId).toBeNull(); - expect(next.environmentStateById[remoteEnvironmentId]).toBeUndefined(); - expect(next.environmentStateById[localEnvironmentId]).toBe(keptState); - }); - - it("preserves active environment when removing a different environment", () => { - const state = makeState(makeThread()); - - const next = removeEnvironmentState(state, remoteEnvironmentId); - - expect(next).toBe(state); - }); -}); - -describe("thread selection memoization", () => { - it("returns stable thread references for repeated reads of the same state", () => { - const thread = makeThread({ - messages: [ - { - id: MessageId.make("message-1"), - role: "user", - text: "hello", - createdAt: "2026-02-13T00:01:00.000Z", - streaming: false, - }, - ], - activities: [ - { - id: EventId.make("activity-1"), - tone: "info", - kind: "step", - summary: "working", - payload: {}, - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-13T00:01:30.000Z", - }, - ], - proposedPlans: [ - { - id: "plan-1", - turnId: null, - planMarkdown: "plan", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-13T00:02:00.000Z", - updatedAt: "2026-02-13T00:02:00.000Z", - }, - ], - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-13T00:03:00.000Z", - files: [], - }, - ], - }); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - - const first = selectThreadByRef(state, ref); - const second = selectThreadByRef(state, ref); - - expect(first).toBeDefined(); - expect(second).toBe(first); - expect(second?.messages).toBe(first?.messages); - expect(second?.activities).toBe(first?.activities); - expect(second?.proposedPlans).toBe(first?.proposedPlans); - expect(second?.turnDiffSummaries).toBe(first?.turnDiffSummaries); - }); - - it("reuses the derived thread when the app state wrapper changes but thread data does not", () => { - const thread = makeThread({ - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "done", - createdAt: "2026-02-13T00:01:00.000Z", - streaming: false, - }, - ], - }); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const wrappedState: AppState = { - ...state, - environmentStateById: { ...state.environmentStateById }, - }; - - const first = selectThreadByRef(state, ref); - const second = selectThreadByRef(wrappedState, ref); - - expect(second).toBe(first); - }); - - it("updates the derived thread when the underlying thread data changes", () => { - const thread = makeThread(); - const ref = scopeThreadRef(thread.environmentId, thread.id); - const firstState = makeState(thread); - const secondState = makeState({ - ...thread, - messages: [ - { - id: MessageId.make("message-2"), - role: "user", - text: "new", - createdAt: "2026-02-13T00:04:00.000Z", - streaming: false, - }, - ], - }); - - const first = selectThreadByRef(firstState, ref); - const second = selectThreadByRef(secondState, ref); - - expect(second).not.toBe(first); - expect(second?.messages).toHaveLength(1); - expect(second?.messages[0]?.text).toBe("new"); - }); - - it("checks thread existence without materializing the full thread", () => { - const thread = makeThread(); - const state = makeState(thread); - const ref = scopeThreadRef(thread.environmentId, thread.id); - - expect(selectThreadExistsByRef(state, ref)).toBe(true); - expect( - selectThreadExistsByRef( - state, - scopeThreadRef(thread.environmentId, ThreadId.make("missing")), - ), - ).toBe(false); - expect(selectThreadExistsByRef(state, null)).toBe(false); - }); -}); - -describe("setThreadBranch", () => { - it("updates only the scoped thread environment", () => { - const sharedThreadId = ThreadId.make("thread-shared"); - const localThread = makeThread({ - id: sharedThreadId, - environmentId: localEnvironmentId, - branch: "local-branch", - }); - const remoteThread = makeThread({ - id: sharedThreadId, - environmentId: remoteEnvironmentId, - branch: "remote-branch", - }); - const state: AppState = { - activeEnvironmentId: localEnvironmentId, - environmentStateById: { - [localEnvironmentId]: environmentStateOf(makeState(localThread), localEnvironmentId), - [remoteEnvironmentId]: environmentStateOf(makeState(remoteThread), remoteEnvironmentId), - }, - }; - - const next = setThreadBranch( - state, - scopeThreadRef(remoteEnvironmentId, sharedThreadId), - "remote-next", - "/tmp/remote-worktree", - ); - - expect( - environmentStateOf(next, localEnvironmentId).threadShellById[sharedThreadId]?.branch, - ).toBe("local-branch"); - expect( - environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.branch, - ).toBe("remote-next"); - expect( - environmentStateOf(next, remoteEnvironmentId).threadShellById[sharedThreadId]?.worktreePath, - ).toBe("/tmp/remote-worktree"); - }); -}); - -describe("incremental orchestration updates", () => { - it("does not mark bootstrap complete for incremental events", () => { - const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(makeThread())), { - bootstrapComplete: false, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.meta-updated", { - threadId: ThreadId.make("thread-1"), - title: "Updated title", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(localEnvironmentStateOf(next).bootstrapComplete).toBe(false); - }); - - it("preserves state identity for no-op project and thread deletes", () => { - const thread = makeThread(); - const state = makeState(thread); - - const nextAfterProjectDelete = applyOrchestrationEvent( - state, - makeEvent("project.deleted", { - projectId: ProjectId.make("project-missing"), - deletedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - const nextAfterThreadDelete = applyOrchestrationEvent( - state, - makeEvent("thread.deleted", { - threadId: ThreadId.make("thread-missing"), - deletedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(nextAfterProjectDelete).toBe(state); - expect(nextAfterThreadDelete).toBe(state); - }); - - it("reuses an existing project row when project.created arrives with a new id for the same cwd", () => { - const originalProjectId = ProjectId.make("project-1"); - const recreatedProjectId = ProjectId.make("project-2"); - const state: AppState = makeEmptyState({ - projectIds: [originalProjectId], - projectById: { - [originalProjectId]: { - id: originalProjectId, - environmentId: localEnvironmentId, - name: "Project", - cwd: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("project.created", { - projectId: recreatedProjectId, - title: "Project Recreated", - workspaceRoot: "/tmp/project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - scripts: [], - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(projectsOf(next)).toHaveLength(1); - expect(projectsOf(next)[0]?.id).toBe(recreatedProjectId); - expect(projectsOf(next)[0]?.cwd).toBe("/tmp/project"); - expect(projectsOf(next)[0]?.name).toBe("Project Recreated"); - expect(localEnvironmentStateOf(next).projectIds).toEqual([recreatedProjectId]); - expect(localEnvironmentStateOf(next).projectById[originalProjectId]).toBeUndefined(); - expect(localEnvironmentStateOf(next).projectById[recreatedProjectId]?.id).toBe( - recreatedProjectId, - ); - }); - - it("removes stale project index entries when thread.created recreates a thread under a new project", () => { - const originalProjectId = ProjectId.make("project-1"); - const recreatedProjectId = ProjectId.make("project-2"); - const threadId = ThreadId.make("thread-1"); - const thread = makeThread({ - id: threadId, - projectId: originalProjectId, - }); - const state = withActiveEnvironmentState(localEnvironmentStateOf(makeState(thread)), { - projectIds: [originalProjectId, recreatedProjectId], - projectById: { - [originalProjectId]: { - id: originalProjectId, - environmentId: localEnvironmentId, - name: "Project 1", - cwd: "/tmp/project-1", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - [recreatedProjectId]: { - id: recreatedProjectId, - environmentId: localEnvironmentId, - name: "Project 2", - cwd: "/tmp/project-2", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - scripts: [], - }, - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.created", { - threadId, - projectId: recreatedProjectId, - title: "Recovered thread", - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - runtimeMode: DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_INTERACTION_MODE, - branch: null, - worktreePath: null, - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)).toHaveLength(1); - expect(threadsOf(next)[0]?.projectId).toBe(recreatedProjectId); - expect(localEnvironmentStateOf(next).threadIdsByProjectId[originalProjectId]).toBeUndefined(); - expect(localEnvironmentStateOf(next).threadIdsByProjectId[recreatedProjectId]).toEqual([ - threadId, - ]); - }); - - it("updates only the affected thread for message events", () => { - const thread1 = makeThread({ - id: ThreadId.make("thread-1"), - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "hello", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:00.000Z", - streaming: false, - }, - ], - }); - const thread2 = makeThread({ id: ThreadId.make("thread-2") }); - const baseState = makeState(thread1); - const baseEnvironmentState = localEnvironmentStateOf(baseState); - const state = withActiveEnvironmentState(baseEnvironmentState, { - threadIds: [thread1.id, thread2.id], - threadShellById: { - ...baseEnvironmentState.threadShellById, - [thread2.id]: { - id: thread2.id, - environmentId: thread2.environmentId, - codexThreadId: thread2.codexThreadId, - projectId: thread2.projectId, - title: thread2.title, - modelSelection: thread2.modelSelection, - runtimeMode: thread2.runtimeMode, - interactionMode: thread2.interactionMode, - error: thread2.error, - createdAt: thread2.createdAt, - archivedAt: thread2.archivedAt, - updatedAt: thread2.updatedAt, - branch: thread2.branch, - worktreePath: thread2.worktreePath, - }, - }, - threadSessionById: { - ...baseEnvironmentState.threadSessionById, - [thread2.id]: thread2.session, - }, - threadTurnStateById: { - ...baseEnvironmentState.threadTurnStateById, - [thread2.id]: { - latestTurn: thread2.latestTurn, - }, - }, - messageIdsByThreadId: { - ...baseEnvironmentState.messageIdsByThreadId, - [thread2.id]: [], - }, - messageByThreadId: { - ...baseEnvironmentState.messageByThreadId, - [thread2.id]: {}, - }, - activityIdsByThreadId: { - ...baseEnvironmentState.activityIdsByThreadId, - [thread2.id]: [], - }, - activityByThreadId: { - ...baseEnvironmentState.activityByThreadId, - [thread2.id]: {}, - }, - proposedPlanIdsByThreadId: { - ...baseEnvironmentState.proposedPlanIdsByThreadId, - [thread2.id]: [], - }, - proposedPlanByThreadId: { - ...baseEnvironmentState.proposedPlanByThreadId, - [thread2.id]: {}, - }, - turnDiffIdsByThreadId: { - ...baseEnvironmentState.turnDiffIdsByThreadId, - [thread2.id]: [], - }, - turnDiffSummaryByThreadId: { - ...baseEnvironmentState.turnDiffSummaryByThreadId, - [thread2.id]: {}, - }, - sidebarThreadSummaryById: { - ...baseEnvironmentState.sidebarThreadSummaryById, - }, - threadIdsByProjectId: { - [thread1.projectId]: [thread1.id, thread2.id], - }, - }); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.message-sent", { - threadId: thread1.id, - messageId: MessageId.make("message-1"), - role: "assistant", - text: " world", - turnId: TurnId.make("turn-1"), - streaming: true, - createdAt: "2026-02-27T00:00:01.000Z", - updatedAt: "2026-02-27T00:00:01.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.messages[0]?.text).toBe("hello world"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - const nextEnvironmentState = next.environmentStateById[localEnvironmentId]; - const previousEnvironmentState = state.environmentStateById[localEnvironmentId]; - expect(nextEnvironmentState?.threadShellById[thread2.id]).toBe( - previousEnvironmentState?.threadShellById[thread2.id], - ); - expect(nextEnvironmentState?.threadSessionById[thread2.id]).toBe( - previousEnvironmentState?.threadSessionById[thread2.id], - ); - expect(nextEnvironmentState?.messageIdsByThreadId[thread2.id]).toBe( - previousEnvironmentState?.messageIdsByThreadId[thread2.id], - ); - expect(nextEnvironmentState?.messageByThreadId[thread2.id]).toBe( - previousEnvironmentState?.messageByThreadId[thread2.id], - ); - }); - - it("applies replay batches in sequence and updates session state", () => { - const thread = makeThread({ - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "running", - requestedAt: "2026-02-27T00:00:00.000Z", - startedAt: "2026-02-27T00:00:00.000Z", - completedAt: null, - assistantMessageId: null, - }, - }); - const state = makeState(thread); - - const next = applyOrchestrationEvents( - state, - [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-02-27T00:00:02.000Z", - }, - }, - { sequence: 2 }, - ), - makeEvent( - "thread.message-sent", - { - threadId: thread.id, - messageId: MessageId.make("assistant-1"), - role: "assistant", - text: "done", - turnId: TurnId.make("turn-1"), - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }, - { sequence: 3 }, - ), - ], - localEnvironmentId, - ); - - // A completed assistant message must not settle the turn while the - // session is still running it — providers emit interim assistant - // messages between tool calls. - expect(threadsOf(next)[0]?.session?.status).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.state).toBe("running"); - expect(threadsOf(next)[0]?.latestTurn?.completedAt).toBeNull(); - expect(threadsOf(next)[0]?.messages).toHaveLength(1); - - const settled = applyOrchestrationEvents( - next, - [ - makeEvent( - "thread.session-set", - { - threadId: thread.id, - session: { - threadId: thread.id, - status: "ready", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: "2026-02-27T00:00:04.000Z", - }, - }, - { sequence: 4 }, - ), - ], - localEnvironmentId, - ); - - // Leaving the running session status is the turn-end signal. - expect(threadsOf(settled)[0]?.latestTurn?.state).toBe("completed"); - expect(threadsOf(settled)[0]?.latestTurn?.completedAt).toBe("2026-02-27T00:00:04.000Z"); - }); - - it("does not regress latestTurn when an older turn diff completes late", () => { - const state = makeState( - makeThread({ - latestTurn: { - turnId: TurnId.make("turn-2"), - state: "running", - requestedAt: "2026-02-27T00:00:02.000Z", - startedAt: "2026-02-27T00:00:03.000Z", - completedAt: null, - assistantMessageId: null, - }, - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.turn-diff-completed", { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("assistant-1"), - completedAt: "2026-02-27T00:00:04.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.turnDiffSummaries).toHaveLength(1); - expect(threadsOf(next)[0]?.latestTurn).toEqual(threadsOf(state)[0]?.latestTurn); - }); - - it("rebinds live turn diffs to the authoritative assistant message when it arrives later", () => { - const turnId = TurnId.make("turn-1"); - const state = makeState( - makeThread({ - latestTurn: { - turnId, - state: "completed", - requestedAt: "2026-02-27T00:00:00.000Z", - startedAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:02.000Z", - assistantMessageId: MessageId.make("assistant:turn-1"), - }, - turnDiffSummaries: [ - { - turnId, - completedAt: "2026-02-27T00:00:02.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-1"), - assistantMessageId: MessageId.make("assistant:turn-1"), - files: [{ path: "src/app.ts", additions: 1, deletions: 0 }], - }, - ], - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.message-sent", { - threadId: ThreadId.make("thread-1"), - messageId: MessageId.make("assistant-real"), - role: "assistant", - text: "final answer", - turnId, - streaming: false, - createdAt: "2026-02-27T00:00:03.000Z", - updatedAt: "2026-02-27T00:00:03.000Z", - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.turnDiffSummaries[0]?.assistantMessageId).toBe( - MessageId.make("assistant-real"), - ); - expect(threadsOf(next)[0]?.latestTurn?.assistantMessageId).toBe( - MessageId.make("assistant-real"), - ); - }); - - it("reverts messages, plans, activities, and checkpoints by retained turns", () => { - const state = makeState( - makeThread({ - messages: [ - { - id: MessageId.make("user-1"), - role: "user", - text: "first", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - completedAt: "2026-02-27T00:00:00.000Z", - streaming: false, - }, - { - id: MessageId.make("assistant-1"), - role: "assistant", - text: "first reply", - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:01.000Z", - completedAt: "2026-02-27T00:00:01.000Z", - streaming: false, - }, - { - id: MessageId.make("user-2"), - role: "user", - text: "second", - turnId: TurnId.make("turn-2"), - createdAt: "2026-02-27T00:00:02.000Z", - completedAt: "2026-02-27T00:00:02.000Z", - streaming: false, - }, - ], - proposedPlans: [ - { - id: "plan-1", - turnId: TurnId.make("turn-1"), - planMarkdown: "plan 1", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-27T00:00:00.000Z", - updatedAt: "2026-02-27T00:00:00.000Z", - }, - { - id: "plan-2", - turnId: TurnId.make("turn-2"), - planMarkdown: "plan 2", - implementedAt: null, - implementationThreadId: null, - createdAt: "2026-02-27T00:00:02.000Z", - updatedAt: "2026-02-27T00:00:02.000Z", - }, - ], - activities: [ - { - id: EventId.make("activity-1"), - tone: "info", - kind: "step", - summary: "one", - payload: {}, - turnId: TurnId.make("turn-1"), - createdAt: "2026-02-27T00:00:00.000Z", - }, - { - id: EventId.make("activity-2"), - tone: "info", - kind: "step", - summary: "two", - payload: {}, - turnId: TurnId.make("turn-2"), - createdAt: "2026-02-27T00:00:02.000Z", - }, - ], - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-27T00:00:01.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - files: [], - }, - { - turnId: TurnId.make("turn-2"), - completedAt: "2026-02-27T00:00:03.000Z", - status: "ready", - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.make("ref-2"), - files: [], - }, - ], - }), - ); - - const next = applyOrchestrationEvent( - state, - makeEvent("thread.reverted", { - threadId: ThreadId.make("thread-1"), - turnCount: 1, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.messages.map((message) => message.id)).toEqual([ - "user-1", - "assistant-1", - ]); - expect(threadsOf(next)[0]?.proposedPlans.map((plan) => plan.id)).toEqual(["plan-1"]); - expect(threadsOf(next)[0]?.activities.map((activity) => activity.id)).toEqual([ - EventId.make("activity-1"), - ]); - expect(threadsOf(next)[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ - TurnId.make("turn-1"), - ]); - }); - - it("clears pending source proposed plans after revert before a new session-set event", () => { - const thread = makeThread({ - latestTurn: { - turnId: TurnId.make("turn-2"), - state: "completed", - requestedAt: "2026-02-27T00:00:02.000Z", - startedAt: "2026-02-27T00:00:02.000Z", - completedAt: "2026-02-27T00:00:03.000Z", - assistantMessageId: MessageId.make("assistant-2"), - sourceProposedPlan: { - threadId: ThreadId.make("thread-source"), - planId: "plan-2" as never, - }, - }, - pendingSourceProposedPlan: { - threadId: ThreadId.make("thread-source"), - planId: "plan-2" as never, - }, - turnDiffSummaries: [ - { - turnId: TurnId.make("turn-1"), - completedAt: "2026-02-27T00:00:01.000Z", - status: "ready", - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - files: [], - }, - { - turnId: TurnId.make("turn-2"), - completedAt: "2026-02-27T00:00:03.000Z", - status: "ready", - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.make("ref-2"), - files: [], - }, - ], - }); - const reverted = applyOrchestrationEvent( - makeState(thread), - makeEvent("thread.reverted", { - threadId: thread.id, - turnCount: 1, - }), - localEnvironmentId, - ); - - expect(threadsOf(reverted)[0]?.pendingSourceProposedPlan).toBeUndefined(); - - const next = applyOrchestrationEvent( - reverted, - makeEvent("thread.session-set", { - threadId: thread.id, - session: { - threadId: thread.id, - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-3"), - lastError: null, - updatedAt: "2026-02-27T00:00:04.000Z", - }, - }), - localEnvironmentId, - ); - - expect(threadsOf(next)[0]?.latestTurn).toMatchObject({ - turnId: TurnId.make("turn-3"), - state: "running", - }); - expect(threadsOf(next)[0]?.latestTurn?.sourceProposedPlan).toBeUndefined(); - }); -}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts deleted file mode 100644 index 12b621e3900..00000000000 --- a/apps/web/src/store.ts +++ /dev/null @@ -1,2059 +0,0 @@ -import type { - EnvironmentId, - MessageId, - OrchestrationCheckpointSummary, - OrchestrationEvent, - OrchestrationLatestTurn, - OrchestrationMessage, - OrchestrationProposedPlan, - OrchestrationReadModel, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, - OrchestrationSession, - OrchestrationSessionStatus, - OrchestrationThread, - OrchestrationThreadShell, - OrchestrationThreadActivity, - ProjectId, - ScopedProjectRef, - ScopedThreadRef, -} from "@t3tools/contracts"; -import { isProviderDriverKind, ProviderDriverKind } from "@t3tools/contracts"; -import type { ThreadId, TurnId } from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { resolveModelSlugForProvider } from "@t3tools/shared/model"; -import { create } from "zustand"; -import { - type ChatMessage, - type Project, - type ProposedPlan, - type SidebarThreadSummary, - type Thread, - type ThreadSession, - type ThreadShell, - type ThreadTurnState, - type TurnDiffSummary, -} from "./types"; -import { resolveEnvironmentHttpUrl } from "./environments/runtime"; -import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; -const isProviderDriverKindValue = Schema.is(ProviderDriverKind); - -export interface EnvironmentState { - projectIds: ProjectId[]; - projectById: Record<ProjectId, Project>; - - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Web still stores shell snapshots and thread details in this denormalized - // Zustand shape. Mobile uses createShellSnapshotManager and - // createThreadDetailManager from @t3tools/client-runtime. New shared behavior - // belongs in those managers/reducers, with a web adapter layered on top. - // - // --------------------------------------------------------------------------- - // Thread bookkeeping — written by BOTH shell stream and detail stream. - // Both streams ensure the thread is registered here; the bookkeeping is - // additive (append-only IDs) so concurrent writes are safe. - // --------------------------------------------------------------------------- - threadIds: ThreadId[]; - threadIdsByProjectId: Record<ProjectId, ThreadId[]>; - - // --------------------------------------------------------------------------- - // Thread shell / session / turn — written by BOTH shell stream and detail - // stream. The shell stream is the *authoritative* source (server pre- - // computes these from the projection pipeline), but the detail stream also - // writes them so the active thread has up-to-date state even if the shell - // event hasn't arrived yet. Structural equality checks in both write - // functions prevent unnecessary React re-renders when both streams deliver - // equivalent data. - // --------------------------------------------------------------------------- - threadShellById: Record<ThreadId, ThreadShell>; - threadSessionById: Record<ThreadId, ThreadSession | null>; - threadTurnStateById: Record<ThreadId, ThreadTurnState>; - - // --------------------------------------------------------------------------- - // Thread detail content — written ONLY by the detail stream - // (writeThreadState / syncServerThreadDetail). The shell stream never - // touches these. - // --------------------------------------------------------------------------- - messageIdsByThreadId: Record<ThreadId, MessageId[]>; - messageByThreadId: Record<ThreadId, Record<MessageId, ChatMessage>>; - activityIdsByThreadId: Record<ThreadId, string[]>; - activityByThreadId: Record<ThreadId, Record<string, OrchestrationThreadActivity>>; - proposedPlanIdsByThreadId: Record<ThreadId, string[]>; - proposedPlanByThreadId: Record<ThreadId, Record<string, ProposedPlan>>; - turnDiffIdsByThreadId: Record<ThreadId, TurnId[]>; - turnDiffSummaryByThreadId: Record<ThreadId, Record<TurnId, TurnDiffSummary>>; - - // --------------------------------------------------------------------------- - // Sidebar summary — written ONLY by the shell stream - // (writeThreadShellState / mapThreadShell). Pre-computed server-side with - // fields like latestUserMessageAt, hasPendingApprovals, etc. The detail - // stream must NOT write here; the shell stream is the single source of - // truth for sidebar data. - // --------------------------------------------------------------------------- - sidebarThreadSummaryById: Record<ThreadId, SidebarThreadSummary>; - - bootstrapComplete: boolean; -} - -export interface AppState { - activeEnvironmentId: EnvironmentId | null; - environmentStateById: Record<string, EnvironmentState>; -} - -const initialEnvironmentState: EnvironmentState = { - projectIds: [], - projectById: {}, - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - messageIdsByThreadId: {}, - messageByThreadId: {}, - activityIdsByThreadId: {}, - activityByThreadId: {}, - proposedPlanIdsByThreadId: {}, - proposedPlanByThreadId: {}, - turnDiffIdsByThreadId: {}, - turnDiffSummaryByThreadId: {}, - sidebarThreadSummaryById: {}, - bootstrapComplete: false, -}; - -const initialState: AppState = { - activeEnvironmentId: null, - environmentStateById: {}, -}; - -const MAX_THREAD_MESSAGES = 2_000; -const MAX_THREAD_CHECKPOINTS = 500; -const MAX_THREAD_PROPOSED_PLANS = 200; -const MAX_THREAD_ACTIVITIES = 500; -const EMPTY_THREAD_IDS: ThreadId[] = []; - -function arraysEqual<T>(left: readonly T[], right: readonly T[]): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -// Accepts the open `instanceId` string carried on `ModelSelection`; malformed -// values pass through unchanged, while valid slugs use any registered alias -// table for model normalization. -function normalizeModelSelection<T extends { instanceId: string; model: string }>(selection: T): T { - if (!isProviderDriverKind(selection.instanceId)) { - return selection; - } - return { - ...selection, - model: resolveModelSlugForProvider(selection.instanceId, selection.model), - }; -} - -function mapProjectScripts(scripts: ReadonlyArray<Project["scripts"][number]>): Project["scripts"] { - return scripts.map((script) => ({ ...script })); -} - -function mapSession(session: OrchestrationSession): ThreadSession { - return { - provider: toLegacyProvider(session.providerName), - providerInstanceId: session.providerInstanceId ?? undefined, - status: toLegacySessionStatus(session.status), - orchestrationStatus: session.status, - activeTurnId: session.activeTurnId ?? undefined, - createdAt: session.updatedAt, - updatedAt: session.updatedAt, - ...(session.lastError ? { lastError: session.lastError } : {}), - }; -} - -function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage): ChatMessage { - const attachments = message.attachments?.map((attachment) => ({ - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: resolveEnvironmentHttpUrl({ - environmentId, - pathname: attachmentPreviewRoutePath(attachment.id), - }), - })); - - return { - id: message.id, - role: message.role, - text: message.text, - turnId: message.turnId, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; -} - -function mapProposedPlan(proposedPlan: OrchestrationProposedPlan): ProposedPlan { - return { - id: proposedPlan.id, - turnId: proposedPlan.turnId, - planMarkdown: proposedPlan.planMarkdown, - implementedAt: proposedPlan.implementedAt, - implementationThreadId: proposedPlan.implementationThreadId, - createdAt: proposedPlan.createdAt, - updatedAt: proposedPlan.updatedAt, - }; -} - -function mapTurnDiffSummary(checkpoint: OrchestrationCheckpointSummary): TurnDiffSummary { - return { - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - }; -} - -function mapProject( - project: - | OrchestrationReadModel["projects"][number] - | OrchestrationShellSnapshot["projects"][number], - environmentId: EnvironmentId, -): Project { - return { - id: project.id, - environmentId, - name: project.title, - cwd: project.workspaceRoot, - repositoryIdentity: project.repositoryIdentity ?? null, - defaultModelSelection: project.defaultModelSelection - ? normalizeModelSelection(project.defaultModelSelection) - : null, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - scripts: mapProjectScripts(project.scripts), - }; -} - -function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): Thread { - return { - id: thread.id, - environmentId, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: normalizeModelSelection(thread.modelSelection), - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - session: thread.session ? mapSession(thread.session) : null, - messages: thread.messages.map((message) => mapMessage(environmentId, message)), - proposedPlans: thread.proposedPlans.map(mapProposedPlan), - error: sanitizeThreadErrorMessage(thread.session?.lastError), - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), - activities: thread.activities.map((activity) => ({ ...activity })), - }; -} - -function mapThreadShell( - thread: OrchestrationThreadShell, - environmentId: EnvironmentId, -): { - shell: ThreadShell; - session: ThreadSession | null; - turnState: ThreadTurnState; - summary: SidebarThreadSummary; -} { - const shell: ThreadShell = { - id: thread.id, - environmentId, - codexThreadId: null, - projectId: thread.projectId, - title: thread.title, - modelSelection: normalizeModelSelection(thread.modelSelection), - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: sanitizeThreadErrorMessage(thread.session?.lastError), - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - }; - const session = thread.session ? mapSession(thread.session) : null; - const turnState: ThreadTurnState = { - latestTurn: thread.latestTurn, - pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, - }; - const summary: SidebarThreadSummary = { - id: thread.id, - environmentId, - projectId: thread.projectId, - title: thread.title, - interactionMode: thread.interactionMode, - session, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - latestTurn: thread.latestTurn, - branch: thread.branch, - worktreePath: thread.worktreePath, - latestUserMessageAt: thread.latestUserMessageAt, - hasPendingApprovals: thread.hasPendingApprovals, - hasPendingUserInput: thread.hasPendingUserInput, - hasActionableProposedPlan: thread.hasActionableProposedPlan, - }; - return { - shell, - session, - turnState, - summary, - }; -} - -function toThreadShell(thread: Thread): ThreadShell { - return { - id: thread.id, - environmentId: thread.environmentId, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - modelSelection: thread.modelSelection, - runtimeMode: thread.runtimeMode, - interactionMode: thread.interactionMode, - error: thread.error, - createdAt: thread.createdAt, - archivedAt: thread.archivedAt, - updatedAt: thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - }; -} - -function toThreadTurnState(thread: Thread): ThreadTurnState { - return { - latestTurn: thread.latestTurn, - ...(thread.pendingSourceProposedPlan - ? { pendingSourceProposedPlan: thread.pendingSourceProposedPlan } - : {}), - }; -} - -function sourceProposedPlansEqual( - left: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, - right: OrchestrationLatestTurn["sourceProposedPlan"] | undefined, -): boolean { - if (left === right) return true; - if (left === undefined || right === undefined) return false; - return left.threadId === right.threadId && left.planId === right.planId; -} - -function latestTurnsEqual( - left: OrchestrationLatestTurn | null | undefined, - right: OrchestrationLatestTurn | null | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return false; - return ( - left.turnId === right.turnId && - left.state === right.state && - left.requestedAt === right.requestedAt && - left.startedAt === right.startedAt && - left.completedAt === right.completedAt && - left.assistantMessageId === right.assistantMessageId && - sourceProposedPlansEqual(left.sourceProposedPlan, right.sourceProposedPlan) - ); -} - -function threadSessionsEqual( - left: ThreadSession | null | undefined, - right: ThreadSession | null | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return false; - return ( - left.provider === right.provider && - left.status === right.status && - left.orchestrationStatus === right.orchestrationStatus && - left.activeTurnId === right.activeTurnId && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt && - left.lastError === right.lastError - ); -} - -function sidebarThreadSummariesEqual( - left: SidebarThreadSummary | undefined, - right: SidebarThreadSummary, -): boolean { - return ( - left !== undefined && - left.id === right.id && - left.projectId === right.projectId && - left.title === right.title && - left.interactionMode === right.interactionMode && - threadSessionsEqual(left.session, right.session) && - left.createdAt === right.createdAt && - left.archivedAt === right.archivedAt && - left.updatedAt === right.updatedAt && - latestTurnsEqual(left.latestTurn, right.latestTurn) && - left.branch === right.branch && - left.worktreePath === right.worktreePath && - left.latestUserMessageAt === right.latestUserMessageAt && - left.hasPendingApprovals === right.hasPendingApprovals && - left.hasPendingUserInput === right.hasPendingUserInput && - left.hasActionableProposedPlan === right.hasActionableProposedPlan - ); -} - -function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): boolean { - return ( - left !== undefined && - left.id === right.id && - left.environmentId === right.environmentId && - left.codexThreadId === right.codexThreadId && - left.projectId === right.projectId && - left.title === right.title && - left.modelSelection === right.modelSelection && - left.runtimeMode === right.runtimeMode && - left.interactionMode === right.interactionMode && - left.error === right.error && - left.createdAt === right.createdAt && - left.archivedAt === right.archivedAt && - left.updatedAt === right.updatedAt && - left.branch === right.branch && - left.worktreePath === right.worktreePath - ); -} - -function threadTurnStatesEqual(left: ThreadTurnState | undefined, right: ThreadTurnState): boolean { - return ( - left !== undefined && - latestTurnsEqual(left.latestTurn, right.latestTurn) && - sourceProposedPlansEqual(left.pendingSourceProposedPlan, right.pendingSourceProposedPlan) - ); -} - -function appendId<T extends string>(ids: readonly T[], id: T): T[] { - return ids.includes(id) ? [...ids] : [...ids, id]; -} - -function removeId<T extends string>(ids: readonly T[], id: T): T[] { - return ids.filter((value) => value !== id); -} - -function buildMessageSlice(thread: Thread): { - ids: MessageId[]; - byId: Record<MessageId, ChatMessage>; -} { - return { - ids: thread.messages.map((message) => message.id), - byId: Object.fromEntries( - thread.messages.map((message) => [message.id, message] as const), - ) as Record<MessageId, ChatMessage>, - }; -} - -function buildActivitySlice(thread: Thread): { - ids: string[]; - byId: Record<string, OrchestrationThreadActivity>; -} { - return { - ids: thread.activities.map((activity) => activity.id), - byId: Object.fromEntries( - thread.activities.map((activity) => [activity.id, activity] as const), - ) as Record<string, OrchestrationThreadActivity>, - }; -} - -function buildProposedPlanSlice(thread: Thread): { - ids: string[]; - byId: Record<string, ProposedPlan>; -} { - return { - ids: thread.proposedPlans.map((plan) => plan.id), - byId: Object.fromEntries( - thread.proposedPlans.map((plan) => [plan.id, plan] as const), - ) as Record<string, ProposedPlan>, - }; -} - -function buildTurnDiffSlice(thread: Thread): { - ids: TurnId[]; - byId: Record<TurnId, TurnDiffSummary>; -} { - return { - ids: thread.turnDiffSummaries.map((summary) => summary.turnId), - byId: Object.fromEntries( - thread.turnDiffSummaries.map((summary) => [summary.turnId, summary] as const), - ) as Record<TurnId, TurnDiffSummary>, - }; -} - -function getProjects(state: EnvironmentState): Project[] { - return state.projectIds.flatMap((projectId) => { - const project = state.projectById[projectId]; - return project ? [project] : []; - }); -} - -function getThreads(state: EnvironmentState): Thread[] { - return state.threadIds.flatMap((threadId) => { - const thread = getThreadFromEnvironmentState(state, threadId); - return thread ? [thread] : []; - }); -} - -/** - * Ensure a thread is registered in the bookkeeping indices (threadIds, - * threadIdsByProjectId). Shared by both the shell stream and detail stream - * write paths — the bookkeeping is additive (append-only IDs) so concurrent - * writes from both streams are safe. - */ -function ensureThreadRegistered( - state: EnvironmentState, - threadId: ThreadId, - nextProjectId: ProjectId, - previousProjectId: ProjectId | undefined, -): EnvironmentState { - let nextState = state; - - if (!state.threadIds.includes(threadId)) { - nextState = { - ...nextState, - threadIds: [...nextState.threadIds, threadId], - }; - } - - if (previousProjectId !== nextProjectId) { - let threadIdsByProjectId = nextState.threadIdsByProjectId; - if (previousProjectId) { - const previousIds = threadIdsByProjectId[previousProjectId] ?? EMPTY_THREAD_IDS; - const nextIds = removeId(previousIds, threadId); - if (nextIds.length === 0) { - const { [previousProjectId]: _removed, ...rest } = threadIdsByProjectId; - threadIdsByProjectId = rest as Record<ProjectId, ThreadId[]>; - } else if (!arraysEqual(previousIds, nextIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [previousProjectId]: nextIds, - }; - } - } - const projectThreadIds = threadIdsByProjectId[nextProjectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = appendId(projectThreadIds, threadId); - if (!arraysEqual(projectThreadIds, nextProjectThreadIds)) { - threadIdsByProjectId = { - ...threadIdsByProjectId, - [nextProjectId]: nextProjectThreadIds, - }; - } - if (threadIdsByProjectId !== nextState.threadIdsByProjectId) { - nextState = { - ...nextState, - threadIdsByProjectId, - }; - } - } - - return nextState; -} - -/** - * Write thread state from the **detail stream** (per-thread subscription). - * - * Owns: messages, activities, proposed plans, turn diff summaries. - * Also writes threadShellById / threadSessionById / threadTurnStateById so - * the active thread has up-to-date state even if the shell stream event - * hasn't arrived yet (both streams use structural equality checks to avoid - * unnecessary re-renders when delivering equivalent data). - * Does NOT write sidebarThreadSummaryById — that is shell-stream-only. - */ -function writeThreadState( - state: EnvironmentState, - nextThread: Thread, - previousThread?: Thread, -): EnvironmentState { - const nextShell = toThreadShell(nextThread); - const nextTurnState = toThreadTurnState(nextThread); - const previousShell = state.threadShellById[nextThread.id]; - const previousTurnState = state.threadTurnStateById[nextThread.id]; - - let nextState = ensureThreadRegistered( - state, - nextThread.id, - nextThread.projectId, - previousThread?.projectId, - ); - - if (!threadShellsEqual(previousShell, nextShell)) { - nextState = { - ...nextState, - threadShellById: { - ...nextState.threadShellById, - [nextThread.id]: nextShell, - }, - }; - } - - if (!threadSessionsEqual(previousThread?.session ?? null, nextThread.session)) { - nextState = { - ...nextState, - threadSessionById: { - ...nextState.threadSessionById, - [nextThread.id]: nextThread.session, - }, - }; - } - - if (!threadTurnStatesEqual(previousTurnState, nextTurnState)) { - nextState = { - ...nextState, - threadTurnStateById: { - ...nextState.threadTurnStateById, - [nextThread.id]: nextTurnState, - }, - }; - } - - if (previousThread?.messages !== nextThread.messages) { - const nextMessageSlice = buildMessageSlice(nextThread); - nextState = { - ...nextState, - messageIdsByThreadId: { - ...nextState.messageIdsByThreadId, - [nextThread.id]: nextMessageSlice.ids, - }, - messageByThreadId: { - ...nextState.messageByThreadId, - [nextThread.id]: nextMessageSlice.byId, - }, - }; - } - - if (previousThread?.activities !== nextThread.activities) { - const nextActivitySlice = buildActivitySlice(nextThread); - nextState = { - ...nextState, - activityIdsByThreadId: { - ...nextState.activityIdsByThreadId, - [nextThread.id]: nextActivitySlice.ids, - }, - activityByThreadId: { - ...nextState.activityByThreadId, - [nextThread.id]: nextActivitySlice.byId, - }, - }; - } - - if (previousThread?.proposedPlans !== nextThread.proposedPlans) { - const nextProposedPlanSlice = buildProposedPlanSlice(nextThread); - nextState = { - ...nextState, - proposedPlanIdsByThreadId: { - ...nextState.proposedPlanIdsByThreadId, - [nextThread.id]: nextProposedPlanSlice.ids, - }, - proposedPlanByThreadId: { - ...nextState.proposedPlanByThreadId, - [nextThread.id]: nextProposedPlanSlice.byId, - }, - }; - } - - if (previousThread?.turnDiffSummaries !== nextThread.turnDiffSummaries) { - const nextTurnDiffSlice = buildTurnDiffSlice(nextThread); - nextState = { - ...nextState, - turnDiffIdsByThreadId: { - ...nextState.turnDiffIdsByThreadId, - [nextThread.id]: nextTurnDiffSlice.ids, - }, - turnDiffSummaryByThreadId: { - ...nextState.turnDiffSummaryByThreadId, - [nextThread.id]: nextTurnDiffSlice.byId, - }, - }; - } - - return nextState; -} - -/** - * Write thread state from the **shell stream** (all-threads subscription). - * - * Owns: sidebarThreadSummaryById (pre-computed server-side sidebar data). - * Also writes threadShellById / threadSessionById / threadTurnStateById as - * the authoritative source for these fields. The detail stream may also - * write them for the focused thread (see writeThreadState); structural - * equality checks prevent unnecessary re-renders. - * Does NOT write message/activity/proposedPlan/turnDiff content — that is - * detail-stream-only. - */ -function writeThreadShellState( - state: EnvironmentState, - nextThread: { - shell: ThreadShell; - session: ThreadSession | null; - turnState: ThreadTurnState; - summary: SidebarThreadSummary; - }, -): EnvironmentState { - const previousShell = state.threadShellById[nextThread.shell.id]; - - let nextState = ensureThreadRegistered( - state, - nextThread.shell.id, - nextThread.shell.projectId, - previousShell?.projectId, - ); - - if (!threadShellsEqual(previousShell, nextThread.shell)) { - nextState = { - ...nextState, - threadShellById: { - ...nextState.threadShellById, - [nextThread.shell.id]: nextThread.shell, - }, - }; - } - - if ( - !threadSessionsEqual(state.threadSessionById[nextThread.shell.id] ?? null, nextThread.session) - ) { - nextState = { - ...nextState, - threadSessionById: { - ...nextState.threadSessionById, - [nextThread.shell.id]: nextThread.session, - }, - }; - } - - if ( - !threadTurnStatesEqual(state.threadTurnStateById[nextThread.shell.id], nextThread.turnState) - ) { - nextState = { - ...nextState, - threadTurnStateById: { - ...nextState.threadTurnStateById, - [nextThread.shell.id]: nextThread.turnState, - }, - }; - } - - if ( - !sidebarThreadSummariesEqual( - state.sidebarThreadSummaryById[nextThread.shell.id], - nextThread.summary, - ) - ) { - nextState = { - ...nextState, - sidebarThreadSummaryById: { - ...nextState.sidebarThreadSummaryById, - [nextThread.shell.id]: nextThread.summary, - }, - }; - } - - return nextState; -} - -function retainThreadScopedRecord<T>( - record: Record<ThreadId, T>, - nextThreadIds: ReadonlySet<ThreadId>, -): Record<ThreadId, T> { - return Object.fromEntries( - Object.entries(record).flatMap(([threadId, value]) => - nextThreadIds.has(threadId as ThreadId) ? [[threadId, value] as const] : [], - ), - ) as Record<ThreadId, T>; -} - -function removeThreadState(state: EnvironmentState, threadId: ThreadId): EnvironmentState { - const shell = state.threadShellById[threadId]; - if (!shell) { - return state; - } - - const nextThreadIds = removeId(state.threadIds, threadId); - const currentProjectThreadIds = state.threadIdsByProjectId[shell.projectId] ?? EMPTY_THREAD_IDS; - const nextProjectThreadIds = removeId(currentProjectThreadIds, threadId); - const nextThreadIdsByProjectId = - nextProjectThreadIds.length === 0 - ? (() => { - const { [shell.projectId]: _removed, ...rest } = state.threadIdsByProjectId; - return rest as Record<ProjectId, ThreadId[]>; - })() - : { - ...state.threadIdsByProjectId, - [shell.projectId]: nextProjectThreadIds, - }; - - const { [threadId]: _removedShell, ...threadShellById } = state.threadShellById; - const { [threadId]: _removedSession, ...threadSessionById } = state.threadSessionById; - const { [threadId]: _removedTurnState, ...threadTurnStateById } = state.threadTurnStateById; - const { [threadId]: _removedMessageIds, ...messageIdsByThreadId } = state.messageIdsByThreadId; - const { [threadId]: _removedMessages, ...messageByThreadId } = state.messageByThreadId; - const { [threadId]: _removedActivityIds, ...activityIdsByThreadId } = state.activityIdsByThreadId; - const { [threadId]: _removedActivities, ...activityByThreadId } = state.activityByThreadId; - const { [threadId]: _removedPlanIds, ...proposedPlanIdsByThreadId } = - state.proposedPlanIdsByThreadId; - const { [threadId]: _removedPlans, ...proposedPlanByThreadId } = state.proposedPlanByThreadId; - const { [threadId]: _removedTurnDiffIds, ...turnDiffIdsByThreadId } = state.turnDiffIdsByThreadId; - const { [threadId]: _removedTurnDiffs, ...turnDiffSummaryByThreadId } = - state.turnDiffSummaryByThreadId; - const { [threadId]: _removedSidebarSummary, ...sidebarThreadSummaryById } = - state.sidebarThreadSummaryById; - - return { - ...state, - threadIds: nextThreadIds, - threadIdsByProjectId: nextThreadIdsByProjectId, - threadShellById, - threadSessionById, - threadTurnStateById, - messageIdsByThreadId, - messageByThreadId, - activityIdsByThreadId, - activityByThreadId, - proposedPlanIdsByThreadId, - proposedPlanByThreadId, - turnDiffIdsByThreadId, - turnDiffSummaryByThreadId, - sidebarThreadSummaryById, - }; -} - -function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { - if (status === "error") { - return "error" as const; - } - if (status === "missing") { - return "interrupted" as const; - } - return "completed" as const; -} - -function compareActivities( - left: Thread["activities"][number], - right: Thread["activities"][number], -): number { - if (left.sequence !== undefined && right.sequence !== undefined) { - if (left.sequence !== right.sequence) { - return left.sequence - right.sequence; - } - } else if (left.sequence !== undefined) { - return 1; - } else if (right.sequence !== undefined) { - return -1; - } - - return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); -} - -function buildLatestTurn(params: { - previous: Thread["latestTurn"]; - turnId: NonNullable<Thread["latestTurn"]>["turnId"]; - state: NonNullable<Thread["latestTurn"]>["state"]; - requestedAt: string; - startedAt: string | null; - completedAt: string | null; - assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"]; - sourceProposedPlan?: Thread["pendingSourceProposedPlan"]; -}): NonNullable<Thread["latestTurn"]> { - const resolvedPlan = - params.previous?.turnId === params.turnId - ? params.previous.sourceProposedPlan - : params.sourceProposedPlan; - return { - turnId: params.turnId, - state: params.state, - requestedAt: params.requestedAt, - startedAt: params.startedAt, - completedAt: params.completedAt, - assistantMessageId: params.assistantMessageId, - ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}), - }; -} - -/** - * Turn state to settle a still-running latest turn with when its session - * leaves the "running" status, or null while the session is (re)starting or - * running and the turn must stay unsettled. - */ -function settledTurnStateForSessionStatus( - status: OrchestrationSessionStatus, -): "completed" | "interrupted" | "error" | null { - switch (status) { - case "idle": - case "ready": - return "completed"; - case "error": - return "error"; - case "interrupted": - case "stopped": - return "interrupted"; - case "starting": - case "running": - return null; - } -} - -function rebindTurnDiffSummariesForAssistantMessage( - turnDiffSummaries: ReadonlyArray<TurnDiffSummary>, - turnId: TurnId, - assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"], -): TurnDiffSummary[] { - let changed = false; - const nextSummaries = turnDiffSummaries.map((summary) => { - if (summary.turnId !== turnId || summary.assistantMessageId === assistantMessageId) { - return summary; - } - changed = true; - return { - ...summary, - assistantMessageId: assistantMessageId ?? undefined, - }; - }); - return changed ? nextSummaries : [...turnDiffSummaries]; -} - -function retainThreadMessagesAfterRevert( - messages: ReadonlyArray<ChatMessage>, - retainedTurnIds: ReadonlySet<string>, - turnCount: number, -): ChatMessage[] { - const retainedMessageIds = new Set<string>(); - for (const message of messages) { - if (message.role === "system") { - retainedMessageIds.add(message.id); - continue; - } - if ( - message.turnId !== undefined && - message.turnId !== null && - retainedTurnIds.has(message.turnId) - ) { - retainedMessageIds.add(message.id); - } - } - - const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), - ).length; - const missingUserCount = Math.max(0, turnCount - retainedUserCount); - if (missingUserCount > 0) { - const fallbackUserMessages = messages - .filter( - (message) => - message.role === "user" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingUserCount); - for (const message of fallbackUserMessages) { - retainedMessageIds.add(message.id); - } - } - - const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), - ).length; - const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); - if (missingAssistantCount > 0) { - const fallbackAssistantMessages = messages - .filter( - (message) => - message.role === "assistant" && - !retainedMessageIds.has(message.id) && - (message.turnId === undefined || - message.turnId === null || - retainedTurnIds.has(message.turnId)), - ) - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(0, missingAssistantCount); - for (const message of fallbackAssistantMessages) { - retainedMessageIds.add(message.id); - } - } - - return messages.filter((message) => retainedMessageIds.has(message.id)); -} - -function retainThreadActivitiesAfterRevert( - activities: ReadonlyArray<OrchestrationThreadActivity>, - retainedTurnIds: ReadonlySet<string>, -): OrchestrationThreadActivity[] { - return activities.filter( - (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), - ); -} - -function retainThreadProposedPlansAfterRevert( - proposedPlans: ReadonlyArray<ProposedPlan>, - retainedTurnIds: ReadonlySet<string>, -): ProposedPlan[] { - return proposedPlans.filter( - (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId), - ); -} - -function toLegacySessionStatus( - status: OrchestrationSessionStatus, -): "connecting" | "ready" | "running" | "error" | "closed" { - switch (status) { - case "starting": - return "connecting"; - case "running": - return "running"; - case "error": - return "error"; - case "ready": - case "interrupted": - return "ready"; - case "idle": - case "stopped": - return "closed"; - } -} - -function toLegacyProvider(providerName: string | null): ProviderDriverKind { - if (isProviderDriverKindValue(providerName)) { - return providerName; - } - return ProviderDriverKind.make("codex"); -} - -function attachmentPreviewRoutePath(attachmentId: string): string { - return `/attachments/${encodeURIComponent(attachmentId)}`; -} - -function updateThreadState( - state: EnvironmentState, - threadId: ThreadId, - updater: (thread: Thread) => Thread, -): EnvironmentState { - const currentThread = getThreadFromEnvironmentState(state, threadId); - if (!currentThread) { - return state; - } - const nextThread = updater(currentThread); - if (nextThread === currentThread) { - return state; - } - return writeThreadState(state, nextThread, currentThread); -} - -function buildProjectState( - projects: ReadonlyArray<Project>, -): Pick<EnvironmentState, "projectIds" | "projectById"> { - return { - projectIds: projects.map((project) => project.id), - projectById: Object.fromEntries( - projects.map((project) => [project.id, project] as const), - ) as Record<ProjectId, Project>, - }; -} - -function getStoredEnvironmentState( - state: AppState, - environmentId: EnvironmentId, -): EnvironmentState { - return state.environmentStateById[environmentId] ?? initialEnvironmentState; -} - -function commitEnvironmentState( - state: AppState, - environmentId: EnvironmentId, - nextEnvironmentState: EnvironmentState, -): AppState { - const currentEnvironmentState = state.environmentStateById[environmentId]; - const environmentStateById = - currentEnvironmentState === nextEnvironmentState - ? state.environmentStateById - : { - ...state.environmentStateById, - [environmentId]: nextEnvironmentState, - }; - - if (environmentStateById === state.environmentStateById) { - return state; - } - - return { - ...state, - environmentStateById, - }; -} - -function syncEnvironmentShellSnapshot( - state: EnvironmentState, - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, -): EnvironmentState { - const nextProjects = snapshot.projects.map((project) => mapProject(project, environmentId)); - const nextThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); - let nextState: EnvironmentState = { - ...state, - ...buildProjectState(nextProjects), - threadIds: [], - threadIdsByProjectId: {}, - threadShellById: {}, - threadSessionById: {}, - threadTurnStateById: {}, - sidebarThreadSummaryById: {}, - messageIdsByThreadId: retainThreadScopedRecord(state.messageIdsByThreadId, nextThreadIds), - messageByThreadId: retainThreadScopedRecord(state.messageByThreadId, nextThreadIds), - activityIdsByThreadId: retainThreadScopedRecord(state.activityIdsByThreadId, nextThreadIds), - activityByThreadId: retainThreadScopedRecord(state.activityByThreadId, nextThreadIds), - proposedPlanIdsByThreadId: retainThreadScopedRecord( - state.proposedPlanIdsByThreadId, - nextThreadIds, - ), - proposedPlanByThreadId: retainThreadScopedRecord(state.proposedPlanByThreadId, nextThreadIds), - turnDiffIdsByThreadId: retainThreadScopedRecord(state.turnDiffIdsByThreadId, nextThreadIds), - turnDiffSummaryByThreadId: retainThreadScopedRecord( - state.turnDiffSummaryByThreadId, - nextThreadIds, - ), - bootstrapComplete: true, - }; - - for (const thread of snapshot.threads) { - nextState = writeThreadShellState(nextState, mapThreadShell(thread, environmentId)); - } - - return nextState; -} - -export function syncServerShellSnapshot( - state: AppState, - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, -): AppState { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Keep web-specific projection here only until the store can consume - // createShellSnapshotManager or a shared adapter over its reducer. - return commitEnvironmentState( - state, - environmentId, - syncEnvironmentShellSnapshot( - getStoredEnvironmentState(state, environmentId), - snapshot, - environmentId, - ), - ); -} - -export function syncServerThreadDetail( - state: AppState, - thread: OrchestrationThread, - environmentId: EnvironmentId, -): AppState { - // TODO(CLIENT-RUNTIME MIGRATION - DO NOT EXPAND THIS WEB-ONLY COPY): - // Keep web-specific projection here only until the store can consume - // createThreadDetailManager or a shared adapter over its reducer. - const environmentState = getStoredEnvironmentState(state, environmentId); - const previousThread = getThreadFromEnvironmentState(environmentState, thread.id); - return commitEnvironmentState( - state, - environmentId, - writeThreadState(environmentState, mapThread(thread, environmentId), previousThread), - ); -} - -function applyEnvironmentOrchestrationEvent( - state: EnvironmentState, - event: OrchestrationEvent, - environmentId: EnvironmentId, -): EnvironmentState { - switch (event.type) { - case "project.created": { - const nextProject = mapProject( - { - id: event.payload.projectId, - title: event.payload.title, - workspaceRoot: event.payload.workspaceRoot, - repositoryIdentity: event.payload.repositoryIdentity ?? null, - defaultModelSelection: event.payload.defaultModelSelection, - scripts: event.payload.scripts, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - deletedAt: null, - }, - environmentId, - ); - const existingProjectId = - state.projectIds.find( - (projectId) => - projectId === event.payload.projectId || - state.projectById[projectId]?.cwd === event.payload.workspaceRoot, - ) ?? null; - let projectById = state.projectById; - let projectIds = state.projectIds; - - if (existingProjectId !== null && existingProjectId !== nextProject.id) { - const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; - projectById = { - ...restProjectById, - [nextProject.id]: nextProject, - }; - projectIds = state.projectIds.map((projectId) => - projectId === existingProjectId ? nextProject.id : projectId, - ); - } else { - projectById = { - ...state.projectById, - [nextProject.id]: nextProject, - }; - projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; - } - - return { - ...state, - projectById, - projectIds, - }; - } - - case "project.meta-updated": { - const project = state.projectById[event.payload.projectId]; - if (!project) { - return state; - } - const nextProject: Project = { - ...project, - ...(event.payload.title !== undefined ? { name: event.payload.title } : {}), - ...(event.payload.workspaceRoot !== undefined ? { cwd: event.payload.workspaceRoot } : {}), - ...(event.payload.repositoryIdentity !== undefined - ? { repositoryIdentity: event.payload.repositoryIdentity ?? null } - : {}), - ...(event.payload.defaultModelSelection !== undefined - ? { - defaultModelSelection: event.payload.defaultModelSelection - ? normalizeModelSelection(event.payload.defaultModelSelection) - : null, - } - : {}), - ...(event.payload.scripts !== undefined - ? { scripts: mapProjectScripts(event.payload.scripts) } - : {}), - updatedAt: event.payload.updatedAt, - }; - return { - ...state, - projectById: { - ...state.projectById, - [event.payload.projectId]: nextProject, - }, - }; - } - - case "project.deleted": { - if (!state.projectById[event.payload.projectId]) { - return state; - } - const { [event.payload.projectId]: _removedProject, ...projectById } = state.projectById; - return { - ...state, - projectById, - projectIds: removeId(state.projectIds, event.payload.projectId), - }; - } - - case "thread.created": { - const previousThread = getThreadFromEnvironmentState(state, event.payload.threadId); - const nextThread = mapThread( - { - id: event.payload.threadId, - projectId: event.payload.projectId, - title: event.payload.title, - modelSelection: event.payload.modelSelection, - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - branch: event.payload.branch, - worktreePath: event.payload.worktreePath, - latestTurn: null, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, - }, - environmentId, - ); - return writeThreadState(state, nextThread, previousThread); - } - - case "thread.deleted": - return removeThreadState(state, event.payload.threadId); - - case "thread.archived": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: event.payload.archivedAt, - updatedAt: event.payload.updatedAt, - })); - - case "thread.unarchived": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - archivedAt: null, - updatedAt: event.payload.updatedAt, - })); - - case "thread.meta-updated": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), - ...(event.payload.worktreePath !== undefined - ? { worktreePath: event.payload.worktreePath } - : {}), - updatedAt: event.payload.updatedAt, - })); - - case "thread.runtime-mode-set": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - runtimeMode: event.payload.runtimeMode, - updatedAt: event.payload.updatedAt, - })); - - case "thread.interaction-mode-set": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - interactionMode: event.payload.interactionMode, - updatedAt: event.payload.updatedAt, - })); - - case "thread.turn-start-requested": - return updateThreadState(state, event.payload.threadId, (thread) => ({ - ...thread, - ...(event.payload.modelSelection !== undefined - ? { modelSelection: normalizeModelSelection(event.payload.modelSelection) } - : {}), - runtimeMode: event.payload.runtimeMode, - interactionMode: event.payload.interactionMode, - pendingSourceProposedPlan: event.payload.sourceProposedPlan, - updatedAt: event.occurredAt, - })); - - case "thread.turn-interrupt-requested": { - if (event.payload.turnId === undefined) { - return state; - } - return updateThreadState(state, event.payload.threadId, (thread) => { - const latestTurn = thread.latestTurn; - if (latestTurn === null || latestTurn.turnId !== event.payload.turnId) { - return thread; - } - return { - ...thread, - latestTurn: buildLatestTurn({ - previous: latestTurn, - turnId: event.payload.turnId, - state: "interrupted", - requestedAt: latestTurn.requestedAt, - startedAt: latestTurn.startedAt ?? event.payload.createdAt, - completedAt: latestTurn.completedAt ?? event.payload.createdAt, - assistantMessageId: latestTurn.assistantMessageId, - }), - updatedAt: event.occurredAt, - }; - }); - } - - case "thread.message-sent": - return updateThreadState(state, event.payload.threadId, (thread) => { - const message = mapMessage(thread.environmentId, { - id: event.payload.messageId, - role: event.payload.role, - text: event.payload.text, - ...(event.payload.attachments !== undefined - ? { attachments: event.payload.attachments } - : {}), - turnId: event.payload.turnId, - streaming: event.payload.streaming, - createdAt: event.payload.createdAt, - updatedAt: event.payload.updatedAt, - }); - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? thread.messages.map((entry) => - entry.id !== message.id - ? entry - : { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), - ...(message.streaming - ? entry.completedAt !== undefined - ? { completedAt: entry.completedAt } - : {} - : message.completedAt !== undefined - ? { completedAt: message.completedAt } - : {}), - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - }, - ) - : [...thread.messages, message]; - const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); - const turnDiffSummaries = - event.payload.role === "assistant" && event.payload.turnId !== null - ? rebindTurnDiffSummariesForAssistantMessage( - thread.turnDiffSummaries, - event.payload.turnId, - event.payload.messageId, - ) - : thread.turnDiffSummaries; - // A completed assistant message only settles the turn once the - // session is no longer running it — providers may emit several - // assistant messages per turn (commentary between tool calls), and - // the turn must stay unsettled until the provider reports turn end. - const turnStillRunning = - event.payload.turnId !== null && - thread.session?.orchestrationStatus === "running" && - thread.session.activeTurnId === event.payload.turnId; - const settlesTurn = !event.payload.streaming && !turnStillRunning; - const latestTurn: Thread["latestTurn"] = - event.payload.role === "assistant" && - event.payload.turnId !== null && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: settlesTurn - ? thread.latestTurn?.state === "interrupted" - ? "interrupted" - : thread.latestTurn?.state === "error" - ? "error" - : "completed" - : "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? thread.latestTurn.requestedAt - : event.payload.createdAt, - startedAt: - thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.startedAt ?? event.payload.createdAt) - : event.payload.createdAt, - sourceProposedPlan: thread.pendingSourceProposedPlan, - completedAt: settlesTurn - ? event.payload.updatedAt - : thread.latestTurn?.turnId === event.payload.turnId - ? (thread.latestTurn.completedAt ?? null) - : null, - assistantMessageId: event.payload.messageId, - }) - : thread.latestTurn; - return { - ...thread, - messages: cappedMessages, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.session-set": - return updateThreadState(state, event.payload.threadId, (thread) => { - // Leaving the "running" session status is the turn-end signal: - // settle a still-running latest turn so its duration reflects the - // whole turn, not the last assistant message. - const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status); - const latestTurn: Thread["latestTurn"] = - event.payload.session.status === "running" && event.payload.session.activeTurnId !== null - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.session.activeTurnId, - state: "running", - requestedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.requestedAt - : event.payload.session.updatedAt, - startedAt: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? (thread.latestTurn.startedAt ?? event.payload.session.updatedAt) - : event.payload.session.updatedAt, - completedAt: null, - assistantMessageId: - thread.latestTurn?.turnId === event.payload.session.activeTurnId - ? thread.latestTurn.assistantMessageId - : null, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn !== null && - thread.latestTurn.state === "running" && - settledTurnState !== null - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: thread.latestTurn.turnId, - state: settledTurnState, - requestedAt: thread.latestTurn.requestedAt, - startedAt: thread.latestTurn.startedAt, - // A running turn's completedAt can only hold a mid-turn - // placeholder checkpoint timestamp — the session leaving - // "running" is the authoritative turn end. - completedAt: event.payload.session.updatedAt, - assistantMessageId: thread.latestTurn.assistantMessageId, - }) - : thread.latestTurn; - return { - ...thread, - session: mapSession(event.payload.session), - error: sanitizeThreadErrorMessage(event.payload.session.lastError), - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.session-stop-requested": - return updateThreadState(state, event.payload.threadId, (thread) => - thread.session === null - ? thread - : { - ...thread, - session: { - ...thread.session, - status: "closed", - orchestrationStatus: "stopped", - activeTurnId: undefined, - updatedAt: event.payload.createdAt, - }, - updatedAt: event.occurredAt, - }, - ); - - case "thread.proposed-plan-upserted": - return updateThreadState(state, event.payload.threadId, (thread) => { - const proposedPlan = mapProposedPlan(event.payload.proposedPlan); - const proposedPlans = [ - ...thread.proposedPlans.filter((entry) => entry.id !== proposedPlan.id), - proposedPlan, - ] - .toSorted( - (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), - ) - .slice(-MAX_THREAD_PROPOSED_PLANS); - return { - ...thread, - proposedPlans, - updatedAt: event.occurredAt, - }; - }); - - case "thread.turn-diff-completed": - return updateThreadState(state, event.payload.threadId, (thread) => { - const checkpoint = mapTurnDiffSummary({ - turnId: event.payload.turnId, - checkpointTurnCount: event.payload.checkpointTurnCount, - checkpointRef: event.payload.checkpointRef, - status: event.payload.status, - files: event.payload.files, - assistantMessageId: event.payload.assistantMessageId, - completedAt: event.payload.completedAt, - }); - const existing = thread.turnDiffSummaries.find( - (entry) => entry.turnId === checkpoint.turnId, - ); - if (existing && existing.status !== "missing" && checkpoint.status === "missing") { - return thread; - } - const turnDiffSummaries = [ - ...thread.turnDiffSummaries.filter((entry) => entry.turnId !== checkpoint.turnId), - checkpoint, - ] - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - // Mid-turn diff updates produce placeholder checkpoints; record the - // diff summary, but don't settle a turn its session is still running. - const turnStillRunning = - thread.session?.orchestrationStatus === "running" && - thread.session.activeTurnId === event.payload.turnId; - const latestTurn = - !turnStillRunning && - (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) - ? buildLatestTurn({ - previous: thread.latestTurn, - turnId: event.payload.turnId, - state: checkpointStatusToLatestTurnState(event.payload.status), - requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, - startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, - completedAt: event.payload.completedAt, - assistantMessageId: event.payload.assistantMessageId, - sourceProposedPlan: thread.pendingSourceProposedPlan, - }) - : thread.latestTurn; - return { - ...thread, - turnDiffSummaries, - latestTurn, - updatedAt: event.occurredAt, - }; - }); - - case "thread.reverted": - return updateThreadState(state, event.payload.threadId, (thread) => { - const turnDiffSummaries = thread.turnDiffSummaries - .filter( - (entry) => - entry.checkpointTurnCount !== undefined && - entry.checkpointTurnCount <= event.payload.turnCount, - ) - .toSorted( - (left, right) => - (left.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER) - - (right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER), - ) - .slice(-MAX_THREAD_CHECKPOINTS); - const retainedTurnIds = new Set(turnDiffSummaries.map((entry) => entry.turnId)); - const messages = retainThreadMessagesAfterRevert( - thread.messages, - retainedTurnIds, - event.payload.turnCount, - ).slice(-MAX_THREAD_MESSAGES); - const proposedPlans = retainThreadProposedPlansAfterRevert( - thread.proposedPlans, - retainedTurnIds, - ).slice(-MAX_THREAD_PROPOSED_PLANS); - const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); - const latestCheckpoint = turnDiffSummaries.at(-1) ?? null; - - return { - ...thread, - turnDiffSummaries, - messages, - proposedPlans, - activities, - pendingSourceProposedPlan: undefined, - latestTurn: - latestCheckpoint === null - ? null - : { - turnId: latestCheckpoint.turnId, - state: checkpointStatusToLatestTurnState( - (latestCheckpoint.status ?? "ready") as "ready" | "missing" | "error", - ), - requestedAt: latestCheckpoint.completedAt, - startedAt: latestCheckpoint.completedAt, - completedAt: latestCheckpoint.completedAt, - assistantMessageId: latestCheckpoint.assistantMessageId ?? null, - }, - updatedAt: event.occurredAt, - }; - }); - - case "thread.activity-appended": - return updateThreadState(state, event.payload.threadId, (thread) => { - const activities = [ - ...thread.activities.filter((activity) => activity.id !== event.payload.activity.id), - { ...event.payload.activity }, - ] - .toSorted(compareActivities) - .slice(-MAX_THREAD_ACTIVITIES); - return { - ...thread, - activities, - updatedAt: event.occurredAt, - }; - }); - - case "thread.approval-response-requested": - case "thread.user-input-response-requested": - return state; - } - - return state; -} - -function applyEnvironmentShellEvent( - state: EnvironmentState, - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, -): EnvironmentState { - switch (event.kind) { - case "project-upserted": { - const nextProject = mapProject(event.project, environmentId); - const existingProjectId = - state.projectIds.find( - (projectId) => - projectId === event.project.id || - state.projectById[projectId]?.cwd === event.project.workspaceRoot, - ) ?? null; - let projectById = state.projectById; - let projectIds = state.projectIds; - - if (existingProjectId !== null && existingProjectId !== nextProject.id) { - const { [existingProjectId]: _removedProject, ...restProjectById } = state.projectById; - projectById = { - ...restProjectById, - [nextProject.id]: nextProject, - }; - projectIds = state.projectIds.map((projectId) => - projectId === existingProjectId ? nextProject.id : projectId, - ); - } else { - projectById = { - ...state.projectById, - [nextProject.id]: nextProject, - }; - projectIds = - existingProjectId === null && !state.projectIds.includes(nextProject.id) - ? [...state.projectIds, nextProject.id] - : state.projectIds; - } - - return { - ...state, - projectById, - projectIds, - }; - } - case "project-removed": { - if (!state.projectById[event.projectId]) { - return state; - } - const { [event.projectId]: _removedProject, ...projectById } = state.projectById; - return { - ...state, - projectById, - projectIds: removeId(state.projectIds, event.projectId), - }; - } - case "thread-upserted": - return writeThreadShellState(state, mapThreadShell(event.thread, environmentId)); - case "thread-removed": - return removeThreadState(state, event.threadId); - } -} - -export function applyOrchestrationEvents( - state: AppState, - events: ReadonlyArray<OrchestrationEvent>, - environmentId: EnvironmentId, -): AppState { - if (events.length === 0) { - return state; - } - const currentEnvironmentState = getStoredEnvironmentState(state, environmentId); - const nextEnvironmentState = events.reduce( - (nextState, event) => applyEnvironmentOrchestrationEvent(nextState, event, environmentId), - currentEnvironmentState, - ); - return commitEnvironmentState(state, environmentId, nextEnvironmentState); -} - -function getEnvironmentEntries( - state: AppState, -): ReadonlyArray<readonly [EnvironmentId, EnvironmentState]> { - return Object.entries(state.environmentStateById) as unknown as ReadonlyArray< - readonly [EnvironmentId, EnvironmentState] - >; -} - -export function selectEnvironmentState( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): EnvironmentState { - return environmentId ? getStoredEnvironmentState(state, environmentId) : initialEnvironmentState; -} - -export function selectProjectsForEnvironment( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): Project[] { - return getProjects(selectEnvironmentState(state, environmentId)); -} - -export function selectThreadsForEnvironment( - state: AppState, - environmentId: EnvironmentId | null | undefined, -): Thread[] { - return getThreads(selectEnvironmentState(state, environmentId)); -} - -export function selectProjectsAcrossEnvironments(state: AppState): Project[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - getProjects(environmentState), - ); -} - -export function selectThreadsAcrossEnvironments(state: AppState): Thread[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - getThreads(environmentState), - ); -} - -/** Like `selectThreadsAcrossEnvironments` but returns stable `ThreadShell` references from the store (no derived data). */ -export function selectThreadShellsAcrossEnvironments(state: AppState): ThreadShell[] { - return getEnvironmentEntries(state).flatMap(([, environmentState]) => - environmentState.threadIds.flatMap((threadId) => { - const shell = environmentState.threadShellById[threadId]; - return shell ? [shell] : []; - }), - ); -} - -export function selectSidebarThreadsAcrossEnvironments(state: AppState): SidebarThreadSummary[] { - return getEnvironmentEntries(state).flatMap(([environmentId, environmentState]) => - environmentState.threadIds.flatMap((threadId) => { - const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread && thread.environmentId === environmentId ? [thread] : []; - }), - ); -} - -export function selectSidebarThreadsForProjectRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): SidebarThreadSummary[] { - if (!ref) { - return []; - } - - const environmentState = selectEnvironmentState(state, ref.environmentId); - const threadIds = environmentState.threadIdsByProjectId[ref.projectId] ?? EMPTY_THREAD_IDS; - return threadIds.flatMap((threadId) => { - const thread = environmentState.sidebarThreadSummaryById[threadId]; - return thread ? [thread] : []; - }); -} - -export function selectSidebarThreadsForProjectRefs( - state: AppState, - refs: readonly ScopedProjectRef[], -): SidebarThreadSummary[] { - if (refs.length === 0) return []; - if (refs.length === 1) return selectSidebarThreadsForProjectRef(state, refs[0]); - return refs.flatMap((ref) => selectSidebarThreadsForProjectRef(state, ref)); -} - -export function selectBootstrapCompleteForActiveEnvironment(state: AppState): boolean { - return selectEnvironmentState(state, state.activeEnvironmentId).bootstrapComplete; -} - -export function selectProjectByRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): Project | undefined { - return ref - ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] - : undefined; -} - -export function selectThreadByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): Thread | undefined { - return ref - ? getThreadFromEnvironmentState(selectEnvironmentState(state, ref.environmentId), ref.threadId) - : undefined; -} - -export function selectThreadExistsByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): boolean { - return ref - ? selectEnvironmentState(state, ref.environmentId).threadShellById[ref.threadId] !== undefined - : false; -} - -export function selectSidebarThreadSummaryByRef( - state: AppState, - ref: ScopedThreadRef | null | undefined, -): SidebarThreadSummary | undefined { - return ref - ? selectEnvironmentState(state, ref.environmentId).sidebarThreadSummaryById[ref.threadId] - : undefined; -} - -export function selectThreadIdsByProjectRef( - state: AppState, - ref: ScopedProjectRef | null | undefined, -): ThreadId[] { - return ref - ? (selectEnvironmentState(state, ref.environmentId).threadIdsByProjectId[ref.projectId] ?? - EMPTY_THREAD_IDS) - : EMPTY_THREAD_IDS; -} - -export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState { - if (state.activeEnvironmentId === null) { - return state; - } - - const nextEnvironmentState = updateThreadState( - getStoredEnvironmentState(state, state.activeEnvironmentId), - threadId, - (thread) => { - if (thread.error === error) return thread; - return { ...thread, error }; - }, - ); - return commitEnvironmentState(state, state.activeEnvironmentId, nextEnvironmentState); -} - -export function applyOrchestrationEvent( - state: AppState, - event: OrchestrationEvent, - environmentId: EnvironmentId, -): AppState { - return commitEnvironmentState( - state, - environmentId, - applyEnvironmentOrchestrationEvent( - getStoredEnvironmentState(state, environmentId), - event, - environmentId, - ), - ); -} - -export function applyShellEvent( - state: AppState, - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, -): AppState { - return commitEnvironmentState( - state, - environmentId, - applyEnvironmentShellEvent( - getStoredEnvironmentState(state, environmentId), - event, - environmentId, - ), - ); -} - -export function setActiveEnvironmentId(state: AppState, environmentId: EnvironmentId): AppState { - if (state.activeEnvironmentId === environmentId) { - return state; - } - - return { - ...state, - activeEnvironmentId: environmentId, - }; -} - -export function removeEnvironmentState(state: AppState, environmentId: EnvironmentId): AppState { - if (!state.environmentStateById[environmentId] && state.activeEnvironmentId !== environmentId) { - return state; - } - - const { [environmentId]: _removed, ...environmentStateById } = state.environmentStateById; - return { - ...state, - activeEnvironmentId: - state.activeEnvironmentId === environmentId ? null : state.activeEnvironmentId, - environmentStateById, - }; -} - -export function setThreadBranch( - state: AppState, - threadRef: ScopedThreadRef, - branch: string | null, - worktreePath: string | null, -): AppState { - const nextEnvironmentState = updateThreadState( - getStoredEnvironmentState(state, threadRef.environmentId), - threadRef.threadId, - (thread) => { - if (thread.branch === branch && thread.worktreePath === worktreePath) return thread; - const cwdChanged = thread.worktreePath !== worktreePath; - return { - ...thread, - branch, - worktreePath, - ...(cwdChanged ? { session: null } : {}), - }; - }, - ); - return commitEnvironmentState(state, threadRef.environmentId, nextEnvironmentState); -} - -interface AppStore extends AppState { - setActiveEnvironmentId: (environmentId: EnvironmentId) => void; - removeEnvironmentState: (environmentId: EnvironmentId) => void; - syncServerShellSnapshot: ( - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, - ) => void; - syncServerThreadDetail: (thread: OrchestrationThread, environmentId: EnvironmentId) => void; - applyOrchestrationEvent: (event: OrchestrationEvent, environmentId: EnvironmentId) => void; - applyOrchestrationEvents: ( - events: ReadonlyArray<OrchestrationEvent>, - environmentId: EnvironmentId, - ) => void; - applyShellEvent: (event: OrchestrationShellStreamEvent, environmentId: EnvironmentId) => void; - setError: (threadId: ThreadId, error: string | null) => void; - setThreadBranch: ( - threadRef: ScopedThreadRef, - branch: string | null, - worktreePath: string | null, - ) => void; -} - -export const useStore = create<AppStore>((set) => ({ - ...initialState, - setActiveEnvironmentId: (environmentId) => - set((state) => setActiveEnvironmentId(state, environmentId)), - removeEnvironmentState: (environmentId) => - set((state) => removeEnvironmentState(state, environmentId)), - syncServerShellSnapshot: (snapshot, environmentId) => - set((state) => syncServerShellSnapshot(state, snapshot, environmentId)), - syncServerThreadDetail: (thread, environmentId) => - set((state) => syncServerThreadDetail(state, thread, environmentId)), - applyOrchestrationEvent: (event, environmentId) => - set((state) => applyOrchestrationEvent(state, event, environmentId)), - applyOrchestrationEvents: (events, environmentId) => - set((state) => applyOrchestrationEvents(state, events, environmentId)), - applyShellEvent: (event, environmentId) => - set((state) => applyShellEvent(state, event, environmentId)), - setError: (threadId, error) => set((state) => setError(state, threadId, error)), - setThreadBranch: (threadRef, branch, worktreePath) => - set((state) => setThreadBranch(state, threadRef, branch, worktreePath)), -})); diff --git a/apps/web/src/storeSelectors.ts b/apps/web/src/storeSelectors.ts deleted file mode 100644 index 95ed6ff1f41..00000000000 --- a/apps/web/src/storeSelectors.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId } from "@t3tools/contracts"; -import { selectEnvironmentState, type AppState, type EnvironmentState } from "./store"; -import { type Project, type Thread } from "./types"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; - -export function createProjectSelectorByRef( - ref: ScopedProjectRef | null | undefined, -): (state: AppState) => Project | undefined { - return (state) => - ref ? selectEnvironmentState(state, ref.environmentId).projectById[ref.projectId] : undefined; -} - -function createScopedThreadSelector( - resolveRef: (state: AppState) => ScopedThreadRef | null | undefined, -): (state: AppState) => Thread | undefined { - let previousEnvironmentState: EnvironmentState | undefined; - let previousThreadId: ThreadId | undefined; - let previousThread: Thread | undefined; - - return (state) => { - const ref = resolveRef(state); - if (!ref) { - return undefined; - } - - const environmentState = selectEnvironmentState(state, ref.environmentId); - if ( - previousThread && - previousEnvironmentState === environmentState && - previousThreadId === ref.threadId - ) { - return previousThread; - } - - previousEnvironmentState = environmentState; - previousThreadId = ref.threadId; - previousThread = getThreadFromEnvironmentState(environmentState, ref.threadId); - return previousThread; - }; -} - -export function createThreadSelectorByRef( - ref: ScopedThreadRef | null | undefined, -): (state: AppState) => Thread | undefined { - return createScopedThreadSelector(() => ref); -} - -export function createThreadSelectorAcrossEnvironments( - threadId: ThreadId | null | undefined, -): (state: AppState) => Thread | undefined { - return createScopedThreadSelector((state) => { - if (!threadId) { - return undefined; - } - - for (const [environmentId, environmentState] of Object.entries( - state.environmentStateById, - ) as Array<[ScopedThreadRef["environmentId"], EnvironmentState]>) { - if (environmentState.threadShellById[threadId]) { - return { - environmentId, - threadId, - }; - } - } - return undefined; - }); -} diff --git a/apps/web/src/terminalSessionState.ts b/apps/web/src/terminalSessionState.ts deleted file mode 100644 index 106a16f8fd7..00000000000 --- a/apps/web/src/terminalSessionState.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { - EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - EMPTY_TERMINAL_ID_LIST_ATOM, - EMPTY_TERMINAL_SESSION_ATOM, - createTerminalSessionManager, - getKnownTerminalSessionTarget, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - runningTerminalIdsAtom, - terminalSessionStateAtom, - type KnownTerminalSession, - type TerminalSessionTarget, - type TerminalSessionState, -} from "@t3tools/client-runtime"; -import type { - EnvironmentId, - TerminalAttachInput, - TerminalSessionSnapshot, - ThreadId, -} from "@t3tools/contracts"; - -import { appAtomRegistry } from "./rpc/atomRegistry"; - -export const terminalSessionManager = createTerminalSessionManager({ - getRegistry: () => appAtomRegistry, -}); - -export function subscribeTerminalMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: Parameters<typeof terminalSessionManager.subscribeMetadata>[0]["client"]; -}) { - return terminalSessionManager.subscribeMetadata(input); -} - -export function attachTerminalSession(input: { - readonly environmentId: EnvironmentId; - readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"]; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: Parameters<typeof terminalSessionManager.attach>[0]["onEvent"]; -}) { - return terminalSessionManager.attach({ - environmentId: input.environmentId, - client: input.client, - terminal: input.terminal, - ...(input.onSnapshot ? { onSnapshot: input.onSnapshot } : {}), - ...(input.onEvent ? { onEvent: input.onEvent } : {}), - }); -} - -export function useTerminalSession(input: TerminalSessionTarget): TerminalSessionState { - const target = getKnownTerminalSessionTarget(input); - return useAtomValue( - target !== null ? terminalSessionStateAtom(target) : EMPTY_TERMINAL_SESSION_ATOM, - ); -} - -export function useKnownTerminalSessions(input: { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; -}): ReadonlyArray<KnownTerminalSession> { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? knownTerminalSessionsAtom(filter) : EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM, - ); -} - -export function useThreadRunningTerminalIds(input: { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; -}): ReadonlyArray<string> { - const filter = getKnownTerminalSessionListFilter(input); - return useAtomValue( - filter !== null ? runningTerminalIdsAtom(filter) : EMPTY_TERMINAL_ID_LIST_ATOM, - ); -} diff --git a/apps/web/src/terminalUiStateStore.test.ts b/apps/web/src/terminalUiStateStore.test.ts index 5782961fdd7..5597c453449 100644 --- a/apps/web/src/terminalUiStateStore.test.ts +++ b/apps/web/src/terminalUiStateStore.test.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; @@ -56,6 +56,24 @@ describe("terminalUiStateStore actions", () => { ]); }); + it("stacks vertically split terminals in the active group", () => { + const store = useTerminalUiStateStore.getState(); + store.setTerminalOpen(THREAD_REF, true); + store.splitTerminalVertical(THREAD_REF, "terminal-2"); + + const terminalUiState = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + THREAD_REF, + ); + expect(terminalUiState.terminalGroups).toEqual([ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "terminal-2"], + splitDirection: "vertical", + }, + ]); + }); + it("materializes the default terminal when opening an empty drawer", () => { useTerminalUiStateStore.getState().setTerminalOpen(THREAD_REF, true); diff --git a/apps/web/src/terminalUiStateStore.ts b/apps/web/src/terminalUiStateStore.ts index 6c10ae448e0..27ae4bbaf05 100644 --- a/apps/web/src/terminalUiStateStore.ts +++ b/apps/web/src/terminalUiStateStore.ts @@ -5,7 +5,7 @@ * API constrained to store actions/selectors. */ -import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime"; +import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { type ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -126,6 +126,7 @@ function normalizeTerminalGroups( nextGroups.push({ id: assignUniqueGroupId(baseGroupId, usedGroupIds), terminalIds: groupTerminalIds, + ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), }); } @@ -155,6 +156,11 @@ function terminalGroupsEqual(left: ThreadTerminalGroup[], right: ThreadTerminalG const rightGroup = right[index]; if (!leftGroup || !rightGroup) return false; if (leftGroup.id !== rightGroup.id) return false; + if ( + (leftGroup.splitDirection ?? "horizontal") !== (rightGroup.splitDirection ?? "horizontal") + ) { + return false; + } if (!arraysEqual(leftGroup.terminalIds, rightGroup.terminalIds)) return false; } return true; @@ -241,6 +247,7 @@ function copyTerminalGroups(groups: ThreadTerminalGroup[]): ThreadTerminalGroup[ return groups.map((group) => ({ id: group.id, terminalIds: [...group.terminalIds], + ...(group.splitDirection === "vertical" ? { splitDirection: "vertical" as const } : {}), })); } @@ -248,6 +255,7 @@ function upsertTerminalIntoGroups( state: ThreadTerminalUiState, terminalId: string, mode: "split" | "new", + splitDirection: "horizontal" | "vertical" = "horizontal", ): ThreadTerminalUiState { const normalized = normalizeThreadTerminalUiState(state); const effectiveMode: "split" | "new" = normalized.terminalIds.length === 0 ? "new" : mode; @@ -323,6 +331,11 @@ function upsertTerminalIntoGroups( destinationGroup.terminalIds.push(terminalId); } } + if (splitDirection === "vertical") { + destinationGroup.splitDirection = "vertical"; + } else { + delete destinationGroup.splitDirection; + } return normalizeThreadTerminalUiState({ ...normalized, @@ -357,8 +370,9 @@ function setThreadTerminalHeight( function splitThreadTerminal( state: ThreadTerminalUiState, terminalId: string, + direction: "horizontal" | "vertical" = "horizontal", ): ThreadTerminalUiState { - return upsertTerminalIntoGroups(state, terminalId, "split"); + return upsertTerminalIntoGroups(state, terminalId, "split", direction); } function newThreadTerminal( @@ -510,6 +524,7 @@ interface TerminalUiStateStoreState { setTerminalOpen: (threadRef: ScopedThreadRef, open: boolean) => void; setTerminalHeight: (threadRef: ScopedThreadRef, height: number) => void; splitTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; + splitTerminalVertical: (threadRef: ScopedThreadRef, terminalId: string) => void; newTerminal: (threadRef: ScopedThreadRef, terminalId: string) => void; ensureTerminal: ( threadRef: ScopedThreadRef, @@ -554,6 +569,8 @@ export const useTerminalUiStateStore = create<TerminalUiStateStoreState>()( updateTerminal(threadRef, (state) => setThreadTerminalHeight(state, height)), splitTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId)), + splitTerminalVertical: (threadRef, terminalId) => + updateTerminal(threadRef, (state) => splitThreadTerminal(state, terminalId, "vertical")), newTerminal: (threadRef, terminalId) => updateTerminal(threadRef, (state) => newThreadTerminal(state, terminalId)), ensureTerminal: (threadRef, terminalId, options) => diff --git a/apps/web/src/threadDerivation.ts b/apps/web/src/threadDerivation.ts deleted file mode 100644 index 0766f0c8e13..00000000000 --- a/apps/web/src/threadDerivation.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; -import type { EnvironmentState } from "./store"; -import type { - ChatMessage, - ProposedPlan, - Thread, - ThreadSession, - ThreadShell, - ThreadTurnState, - TurnDiffSummary, -} from "./types"; - -const EMPTY_MESSAGES: ChatMessage[] = []; -const EMPTY_ACTIVITIES: Thread["activities"] = []; -const EMPTY_PROPOSED_PLANS: ProposedPlan[] = []; -const EMPTY_TURN_DIFF_SUMMARIES: TurnDiffSummary[] = []; -const EMPTY_MESSAGE_MAP: Record<MessageId, ChatMessage> = {}; -const EMPTY_ACTIVITY_MAP: Record<string, Thread["activities"][number]> = {}; -const EMPTY_PROPOSED_PLAN_MAP: Record<string, ProposedPlan> = {}; -const EMPTY_TURN_DIFF_MAP: Record<TurnId, TurnDiffSummary> = {}; - -const collectedByIdsCache = new WeakMap<readonly string[], WeakMap<object, readonly unknown[]>>(); -const threadCache = new WeakMap< - ThreadShell, - { - session: ThreadSession | null; - turnState: ThreadTurnState | undefined; - messages: Thread["messages"]; - activities: Thread["activities"]; - proposedPlans: Thread["proposedPlans"]; - turnDiffSummaries: Thread["turnDiffSummaries"]; - thread: Thread; - } ->(); - -function collectByIds<TKey extends string, TValue>( - ids: readonly TKey[] | undefined, - byId: Record<TKey, TValue> | undefined, - emptyValue: TValue[], -): TValue[] { - if (!ids || ids.length === 0 || !byId) { - return emptyValue; - } - - const cachedByRecord = collectedByIdsCache.get(ids); - const cached = cachedByRecord?.get(byId); - if (cached) { - return cached as TValue[]; - } - - const nextValues = ids.flatMap((id) => { - const value = byId[id]; - return value ? [value] : []; - }); - const nextCachedByRecord = cachedByRecord ?? new WeakMap<object, readonly unknown[]>(); - nextCachedByRecord.set(byId, nextValues); - if (!cachedByRecord) { - collectedByIdsCache.set(ids, nextCachedByRecord); - } - return nextValues; -} - -function selectThreadMessages(state: EnvironmentState, threadId: ThreadId): Thread["messages"] { - return collectByIds( - state.messageIdsByThreadId[threadId], - state.messageByThreadId[threadId] ?? EMPTY_MESSAGE_MAP, - EMPTY_MESSAGES, - ); -} - -function selectThreadActivities(state: EnvironmentState, threadId: ThreadId): Thread["activities"] { - return collectByIds( - state.activityIdsByThreadId[threadId], - state.activityByThreadId[threadId] ?? EMPTY_ACTIVITY_MAP, - EMPTY_ACTIVITIES, - ); -} - -function selectThreadProposedPlans( - state: EnvironmentState, - threadId: ThreadId, -): Thread["proposedPlans"] { - return collectByIds( - state.proposedPlanIdsByThreadId[threadId], - state.proposedPlanByThreadId[threadId] ?? EMPTY_PROPOSED_PLAN_MAP, - EMPTY_PROPOSED_PLANS, - ); -} - -function selectThreadTurnDiffSummaries( - state: EnvironmentState, - threadId: ThreadId, -): Thread["turnDiffSummaries"] { - return collectByIds( - state.turnDiffIdsByThreadId[threadId], - state.turnDiffSummaryByThreadId[threadId] ?? EMPTY_TURN_DIFF_MAP, - EMPTY_TURN_DIFF_SUMMARIES, - ); -} - -export function getThreadFromEnvironmentState( - state: EnvironmentState, - threadId: ThreadId, -): Thread | undefined { - const shell = state.threadShellById[threadId]; - if (!shell) { - return undefined; - } - - const session = state.threadSessionById[threadId] ?? null; - const turnState = state.threadTurnStateById[threadId]; - const messages = selectThreadMessages(state, threadId); - const activities = selectThreadActivities(state, threadId); - const proposedPlans = selectThreadProposedPlans(state, threadId); - const turnDiffSummaries = selectThreadTurnDiffSummaries(state, threadId); - const cached = threadCache.get(shell); - - if ( - cached && - cached.session === session && - cached.turnState === turnState && - cached.messages === messages && - cached.activities === activities && - cached.proposedPlans === proposedPlans && - cached.turnDiffSummaries === turnDiffSummaries - ) { - return cached.thread; - } - - const thread: Thread = { - ...shell, - session, - latestTurn: turnState?.latestTurn ?? null, - pendingSourceProposedPlan: turnState?.pendingSourceProposedPlan, - messages, - activities, - proposedPlans, - turnDiffSummaries, - }; - - threadCache.set(shell, { - session, - turnState, - messages, - activities, - proposedPlans, - turnDiffSummaries, - thread, - }); - - return thread; -} diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index e5a365b0889..d15a233a304 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { DraftId } from "./composerDraftStore"; diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 3fda9eb4235..19a7d5ca603 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -1,4 +1,4 @@ -import { scopeThreadRef } from "@t3tools/client-runtime"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { DraftId } from "./composerDraftStore"; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c2e4b235e21..45a8539a151 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,22 +1,20 @@ import type { - EnvironmentId, - ModelSelection, + ChatImageAttachment as ContractChatImageAttachment, + OrchestrationCheckpointFile, + OrchestrationCheckpointSummary, OrchestrationLatestTurn, - OrchestrationProposedPlanId, - RepositoryIdentity, - OrchestrationSessionStatus, - OrchestrationThreadActivity, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, ProjectScript as ContractProjectScript, - ThreadId, - ProjectId, - TurnId, - MessageId, - ProviderDriverKind, - ProviderInstanceId, - CheckpointRef, ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; +import type { + EnvironmentProject, + EnvironmentThread, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; @@ -30,141 +28,30 @@ export type ProjectScript = ContractProjectScript; export interface ThreadTerminalGroup { id: string; terminalIds: string[]; + splitDirection?: "horizontal" | "vertical"; } -export interface ChatImageAttachment { - type: "image"; - id: string; - name: string; - mimeType: string; - sizeBytes: number; - previewUrl?: string; +export interface ChatImageAttachment extends ContractChatImageAttachment { + readonly previewUrl?: string; } export type ChatAttachment = ChatImageAttachment; -export interface ChatMessage { - id: MessageId; - role: "user" | "assistant" | "system"; - text: string; - attachments?: ChatAttachment[]; - turnId?: TurnId | null; - createdAt: string; - completedAt?: string | undefined; - streaming: boolean; +export interface ChatMessage extends Omit<OrchestrationMessage, "attachments"> { + readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; } -export interface ProposedPlan { - id: OrchestrationProposedPlanId; - turnId: TurnId | null; - planMarkdown: string; - implementedAt: string | null; - implementationThreadId: ThreadId | null; - createdAt: string; - updatedAt: string; -} +export type ProposedPlan = OrchestrationProposedPlan; +export type TurnDiffFileChange = OrchestrationCheckpointFile; +export type TurnDiffSummary = OrchestrationCheckpointSummary; -export interface TurnDiffFileChange { - path: string; - kind?: string | undefined; - additions?: number | undefined; - deletions?: number | undefined; -} - -export interface TurnDiffSummary { - turnId: TurnId; - completedAt: string; - status?: string | undefined; - files: TurnDiffFileChange[]; - checkpointRef?: CheckpointRef | undefined; - assistantMessageId?: MessageId | undefined; - checkpointTurnCount?: number | undefined; -} - -export interface Project { - id: ProjectId; - environmentId: EnvironmentId; - name: string; - cwd: string; - repositoryIdentity?: RepositoryIdentity | null; - defaultModelSelection: ModelSelection | null; - createdAt?: string | undefined; - updatedAt?: string | undefined; - scripts: ProjectScript[]; -} - -export interface Thread { - id: ThreadId; - environmentId: EnvironmentId; - codexThreadId: string | null; - projectId: ProjectId; - title: string; - modelSelection: ModelSelection; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - session: ThreadSession | null; - messages: ChatMessage[]; - proposedPlans: ProposedPlan[]; - error: string | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - latestTurn: OrchestrationLatestTurn | null; - pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; - branch: string | null; - worktreePath: string | null; - turnDiffSummaries: TurnDiffSummary[]; - activities: OrchestrationThreadActivity[]; -} - -export interface ThreadShell { - id: ThreadId; - environmentId: EnvironmentId; - codexThreadId: string | null; - projectId: ProjectId; - title: string; - modelSelection: ModelSelection; - runtimeMode: RuntimeMode; - interactionMode: ProviderInteractionMode; - error: string | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - branch: string | null; - worktreePath: string | null; -} +export type Project = EnvironmentProject; +export type Thread = EnvironmentThread; +export type ThreadShell = EnvironmentThreadShell; export interface ThreadTurnState { latestTurn: OrchestrationLatestTurn | null; - pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; } -export interface SidebarThreadSummary { - id: ThreadId; - environmentId: EnvironmentId; - projectId: ProjectId; - title: string; - interactionMode: ProviderInteractionMode; - session: ThreadSession | null; - createdAt: string; - archivedAt: string | null; - updatedAt?: string | undefined; - latestTurn: OrchestrationLatestTurn | null; - branch: string | null; - worktreePath: string | null; - latestUserMessageAt: string | null; - hasPendingApprovals: boolean; - hasPendingUserInput: boolean; - hasActionableProposedPlan: boolean; -} - -export interface ThreadSession { - provider: ProviderDriverKind; - providerInstanceId?: ProviderInstanceId | undefined; - status: SessionPhase | "error" | "closed"; - activeTurnId?: TurnId | undefined; - createdAt: string; - updatedAt: string; - lastError?: string; - orchestrationStatus: OrchestrationSessionStatus; -} +export type SidebarThreadSummary = EnvironmentThreadShell; +export type ThreadSession = OrchestrationSession; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 0354a966996..13ff2f0f73e 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -10,7 +10,6 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { return { id: ThreadId.make("thread-1"), environmentId: localEnvironmentId, - codexThreadId: null, projectId: ProjectId.make("project-1"), title: "Thread", modelSelection: { @@ -21,12 +20,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { interactionMode: DEFAULT_INTERACTION_MODE, session: null, messages: [], - turnDiffSummaries: [], + checkpoints: [], activities: [], proposedPlans: [], - error: null, createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", archivedAt: null, + deletedAt: null, latestTurn: null, branch: null, worktreePath: null, diff --git a/apps/web/src/worktreeCleanup.ts b/apps/web/src/worktreeCleanup.ts index 8c09e89afa4..109f71ccd9a 100644 --- a/apps/web/src/worktreeCleanup.ts +++ b/apps/web/src/worktreeCleanup.ts @@ -1,4 +1,4 @@ -import type { Thread } from "./types"; +import type { ThreadShell } from "./types"; function normalizeWorktreePath(path: string | null): string | null { const trimmed = path?.trim(); @@ -9,8 +9,8 @@ function normalizeWorktreePath(path: string | null): string | null { } export function getOrphanedWorktreePathForThread( - threads: readonly Thread[], - threadId: Thread["id"], + threads: ReadonlyArray<Pick<ThreadShell, "id" | "worktreePath">>, + threadId: ThreadShell["id"], ): string | null { const targetThread = threads.find((thread) => thread.id === threadId); if (!targetThread) { diff --git a/docs/architecture/connection-runtime.md b/docs/architecture/connection-runtime.md new file mode 100644 index 00000000000..1ad59466642 --- /dev/null +++ b/docs/architecture/connection-runtime.md @@ -0,0 +1,133 @@ +# Connection Runtime + +The connection runtime is shared by web and mobile. It owns connectivity, +authentication, retries, transport lifetime, cached environment data, and +environment-scoped operations. + +Web and mobile mount this runtime once at the application root. There is no +legacy connection owner or supported mixed mode. + +## Ownership + +Each registered environment has one scoped Effect `Context` containing focused +services: + +- `EnvironmentSupervisor` owns desired state, retry scheduling, and the active + session scope. +- `ConnectionBroker` prepares credentials and endpoints for primary, bearer, + relay, and SSH targets. +- `RpcSessionFactory` performs one transport attempt. It does not retry. +- `EnvironmentRpc` exposes the active session without leaking the transport. +- `EnvironmentProjectCommands` and `EnvironmentThreadCommands` construct + orchestration commands, IDs, and timestamps. +- `EnvironmentShell` and `EnvironmentThreads` own live subscriptions and cached + snapshots. + +`EnvironmentServicesFactory` assembles that context, and `EnvironmentRegistry` +owns its scope. There is no aggregate environment runtime facade. React +components do not create connections, transports, retry loops, or RPC clients. + +## Connection State + +The supervisor is the only retry owner. + +1. A persisted or platform registration marks an environment as desired. +2. If the device is offline, the supervisor releases the active session and + waits without consuming retry attempts. +3. When online, the supervisor asks the broker for one prepared connection and + asks the session factory for one RPC session. +4. Transient failures retry forever with exponential backoff capped at 16 + seconds. +5. Connectivity changes, application activation, credential changes, and + explicit user retry interrupt the current wait and trigger a fresh attempt. +6. Authentication or configuration failures remain blocked until an external + wakeup changes the relevant input. +7. An involuntary session close keeps the registration and cache, then retries. +8. Explicit removal closes the session and deletes the registration, + credentials, shell cache, and thread cache. + +The UI derives `available`, `offline`, `connecting`, `reconnecting`, +`connected`, and `error` from supervisor state plus explicit data-sync state. +It does not infer connection health from cached data or the existence of a +transport object. A healthy RPC transport with a failed mandatory shell +subscription is shown as a synchronization error, not as a reconnect that is +not actually scheduled. + +## Data Boundary + +Finite requests, durable subscriptions, and commands are separate APIs: + +- Query atoms revalidate when the RPC generation changes. +- Subscription atoms switch to replacement sessions. +- Mutations resolve the current environment runtime at execution time. +- Shell and thread snapshots are available while offline. +- A ready transport remains `synchronizing` until the first live shell snapshot + arrives. +- Cached shell and thread projections are never allowed to overwrite newer live + data during a fast reconnect. +- Domain atom factories route effects through the environment registry and + resolve the current scoped service at execution time. +- Web and mobile own their Atom runtimes, React hooks, and feature composition. + +The Promise bridge exists only at the React/Atom boundary. Runtime and business +logic remain Effect-native. + +## Platform Layers + +Web and mobile provide: + +- network status and network-change streams; +- application lifecycle wakeups; +- cloud session credentials; +- device identity; +- platform registrations; +- persistent catalog, credential, shell, and thread stores; +- HTTP, crypto, and telemetry layers. + +Platform layers adapt operating-system capabilities. They do not implement +connection policy. + +## Source Boundaries + +The public package subpaths mirror the runtime layers: + +- `connection/core` contains state, catalog, retry policy, and connectivity. +- `connection/transport` contains brokerage, authorization, attempts, and RPC + sessions. +- `connection/platform` declares capabilities and persistence contracts. +- `connection/services` contains environment-scoped data services. +- `connection/application` assembles registries, discovery, and startup. +- `connection/atoms` adapts shared services to application-owned Atom runtimes. +- `connection/presentation` contains pure UI projections. + +Other reusable state lives in domain subpaths such as `shell`, `threads`, +`terminal`, and `vcs`. Applications must import explicit package subpaths; the +package intentionally has no root export. + +## Application Boundary + +The application root mounts the shared connection application layer, creates +its own Atom runtime, and selects the domain atom factories required by that +platform. Web and mobile may expose different hooks and features without +changing connection ownership. + +Application code must not construct `WsTransport`, RPC clients, retry loops, or +raw orchestration commands. Persistence paths belong to the platform +registration and cache stores, with explicit migration or invalidation policy. + +## Verification + +Core state-machine tests use `@effect/vitest` and deterministic service layers. +Required coverage includes: + +- offline startup and online wakeup; +- forever retry with the 16-second cap; +- explicit retry interrupting backoff; +- authentication wakeups; +- involuntary close and reconnect; +- explicit removal clearing all owned state; +- relay token reuse and refresh; +- progressive relay discovery; +- shell and thread cache hydration; +- durable subscriptions switching sessions; +- command metadata and idempotent queued-command metadata. diff --git a/docs/browser-automation-plan.html b/docs/browser-automation-plan.html new file mode 100644 index 00000000000..9a1e05aadbe --- /dev/null +++ b/docs/browser-automation-plan.html @@ -0,0 +1,452 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1" /> +<title>t3code — Browser Preview & Automation: Architecture Plan + + + + + + +
+ +
+
+ t3code / engineering plan· + branch: codex/browser-preview-port· + 2026-06-12 +
+

Browser preview & automation — where it is, where it goes.

+

+ The in-app browser preview is a shared user/agent surface: an Electron webview the user watches and annotates, + and the agent drives over MCP. This plan records the current architecture, its honest strengths and weaknesses + against OpenAI Codex's browser stack, and the target architecture that keeps our edges while stealing their middle. +

+
+ scope desktop preview panel · MCP automation · annotation pipeline + benchmark Codex iab / chrome / computer-use plugins +
+
+ + +
+
01

Current architecture

+

One automation request traverses six hops. Execution brains (CDP) already live in Electron main — but the + routing brains live in a React component, and the panel must be visible for any of it to run.

+ +
+
+
+ Agent → PreviewToolkit + 10 typed MCP tools: status · open · navigate · snapshot · click · type · press · scroll · evaluate · wait_for + server / mcp +
+
Effect RPC, schema-validated
+
+ PreviewAutomationBroker + Owner election per (environment, thread): supportsAutomation ∧ visible, sorted by focusedAt · 15s timeout per op + server +
+
WebSocket (client-initiated — works cross-machine)
+
+ PreviewAutomationOwner (React) + Mounted per <ThreadComposer>; re-registers on mount/focus/tab change; pure forwarder to previewBridge + web renderer · fragile +
+
Electron IPC (previewBridge)
+
+ PreviewViewManager + Owns WebContents per tab · CDP debugger: input, evaluate, a11y tree, capturePage · pickElement crop + electron main +
+
CDP
+
+ <webview> · persist:t3code-preview + sandbox=true · contextIsolation=false (React DevTools hook) · annotation preload (pick, marquee, strokes, CSS diffs) + guest page +
+
+

Sessions keyed (threadId, tabId) on the server, survive reconnects · URL policy localhost-only, enforced in code · Visibility panel must be open; no headless mode.

+
+ +
+

Typed tool surface

Ten schema-validated operations with Effect-typed errors. Cheap to prompt, hard to misuse — no 900 KB API doc in context.

+

One-shot perception

preview_snapshot bundles screenshot + 20k chars text + 200 interactive elements + full a11y tree in a single call.

+

Annotation pipeline

User picks/marks elements in-page; agent receives crop + CSS selector + React component name + source-mapped file:line. Codex has no equivalent.

+

Dev-loop integration

Port scanner auto-discovers localhost servers; previewUrl auto-opens; panel sits beside plan/diff with devtools, zoom, storage controls.

+
+
+ + +
+
02

Strengths / weaknesses

+

Benchmarked against Codex's stack — which, per bundle inspection, is the same substrate (Electron WebContents + CDP) with a different middle: a Node-REPL Playwright wrapper talking to Electron main over a machine-local named pipe.

+ +
+
+

Strengths — keep these

+
    +
  • Shared surface. User and agent look at the same live page; annotations close the loop both ways.
  • +
  • Code-aware. DOM → React component → source file:line. Codex's browser doesn't know what repo it's in.
  • +
  • Cross-machine by design. Server-mediated broker works with remote environment servers (mac mini). Codex's /tmp/codex-browser-use pipe is same-machine only.
  • +
  • Enforced safety. URL policy lives in code, not in skill prose. Typed contracts end-to-end.
  • +
+
+
+

Weaknesses — fix these

+
    +
  • Routing brains in React. Handler dies with component churn; focus-dependent owner election.
  • +
  • No headless. Visible panel + desktop client required for any automation.
  • +
  • Flaky targeting. CSS-selector heuristics + manual wait_for vs Playwright's role/name locators with auto-waiting.
  • +
  • Round-trip tax. One op per broker round-trip; complex flows cost 20+ hops.
  • +
  • Perception gaps. No console or network capture; snapshot is all-or-nothing (token-heavy).
  • +
  • Scope. Localhost-only; remote envs resolve localhost to the wrong machine; contextIsolation=false soft spot.
  • +
+
+
+
+ + +
+
03

Target architecture

+

Three layers, each where it belongs: protocol on the server, routing in shared client code, execution behind a + per-platform capability interface. The web app keeps everything it has and gains a defined slot to grow into.

+ +
+
+
+ layer 1 · server +

Protocol + PreviewAutomationBroker

+

Contracts and broker unchanged in shape. Owner abstraction widened: owners self-describe, and a future + server-side headless owner (Chromium on the environment server) slots in beside desktop owners — full automation + for web-app users, and pages render where the dev server lives. Election simplifies to "which owner has (or can create) + a surface for this thread"; focusedAt is only a tiebreaker; visible no longer gates execution.

+ unchanged shape · widened semantics +
+
WebSocket per environment server (client dials out — laptop ↔ mac mini)
+
+ layer 2 · shared client core +

Automation transport singleton

+

Plain TS module in shared web code — identical on app.t3.codes, environment-served web, and desktop. Lifecycle is + app session, not React mount: subscribes to broker requests per connected server, routes to a registered + capability provider, returns responses. Owns nothing platform-specific; replaces PreviewAutomationOwner.

+ new · replaces React-mounted handler +
+
registerPreviewAutomationProvider(provider)
+
+ layer 3 · capability providers +

Execution, where a Chromium actually lives

+
+
+
Desktop · electron main
+

Thin IPC shim → PreviewViewManager holds the authoritative tab registry and offscreen/hidden + WebContents decoupled from the panel: headless by default, attach to panel on user toggle or show: true.

+
+
+
Web · today none, later optional
+

Registers no provider (status quo, honestly expressed). Future: same-origin iframe preview, or parity via the + server-side headless owner in layer 1 with streamed frames.

+
+
+ brains consolidated in main · interface keeps client layer agnostic +
+
+
+
+ + +
+
04

Capability roadmap

+

Phase 1 alone beats Codex's iab for the localhost dev loop. Phases 2–3 add their reach without their trust model, plus the code-aware features they structurally can't build.

+ +
+
+
Phase 1

Reliability & power

parity for existing scope

+
    +
  • Locator engine — role/name, testid, text, label; vendored Playwright-style injected script over CDP.
  • +
  • Auto-wait actionability in every action: visible · stable · enabled · unobscured. Kills most flakes and most wait_for calls.
  • +
  • preview_batch — array of typed ops, fail-fast, one broker round-trip, optional snapshot-on-completion.
  • +
  • preview_console / preview_network — ring-buffered logs, exceptions, failed requests.
  • +
  • Lean perception — snapshot include flags + cheap preview_query(locator).
  • +
+
+
+
Phase 2

Reach

topology & scope

+
    +
  • Layer split — transport singleton + provider interface; retire React-mounted owner.
  • +
  • Headless decoupling — hidden WebContents, panel attach on demand; automation works with panel closed.
  • +
  • Remote-localhost mapping — env-server-relative URLs (tailscale addr or port tunnel) so the mac mini's localhost resolves correctly.
  • +
  • contextIsolation: true hardening (main-world hook via early injection) — prerequisite for next item.
  • +
  • Per-domain consent — enforced allowlist with one-click UI grant; hard-deny stays for file: / data: / javascript:.
  • +
+
+
+
Phase 3

Differentiation

what Codex can't follow

+
    +
  • preview_inspect(locator) — DOM element → React component → source file:line, as an agent tool.
  • +
  • Agent-drawn annotations — agent highlights its changes on the shared surface via the existing overlay.
  • +
  • Tab semanticstabId on tools; agent tabs labeled; deliverable / scratch on completion.
  • +
  • Preview skill doc — locator ladder, query-before-snapshot discipline, when to show.
  • +
  • Server-side headless owner — web-app automation parity, frames streamed to the web panel.
  • +
+
+
+
+ + +
+
05

Versus Codex — keep · steal · skip

+

Their in-app browser is our architecture at the surface layer. Their superior pieces are all middle-layer and all compatible with ours; their distinctive choices trade away the things that differentiate us.

+ +
+
+

Keep (ours)

+
    +
  • Server-mediated broker — the only cross-machine design
  • +
  • Typed MCP tool surface + contracts
  • +
  • Visible collaborative panel + annotation pipeline
  • +
  • Code-enforced URL / consent policy
  • +
+
+
+

Steal (theirs)

+
    +
  • Execution authority in Electron main, not renderer
  • +
  • Hidden-by-default tabs, visibility as a capability
  • +
  • Playwright locator discipline + auto-waiting
  • +
  • Batched expressiveness (their REPL's real win)
  • +
  • Versioned guidance docs as cheap behavior shipping
  • +
+
+
+

Skip (theirs)

+
    +
  • Node REPL as the agent surface (ships a runtime; arbitrary JS as core primitive)
  • +
  • User-Chrome takeover via extension (their biggest risk surface)
  • +
  • Machine-local named pipe (breaks remote environments by construction)
  • +
  • Prompt-enforced safety policy
  • +
+
+
+
+ +
+ t3code · browser preview & automation plan + sources: codex/browser-preview-port · ~/.codex/plugins/cache/openai-bundled · /Applications/Codex.app bundle inspection +
+ +
+ + diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 67652cc957d..254aa92c6a0 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "mod+shift+j", "command": "preview.toggle" }, + { "key": "mod+r", "command": "preview.refresh", "when": "previewFocus" }, + { "key": "mod+l", "command": "preview.focusUrl", "when": "previewFocus" }, + { "key": "mod+=", "command": "preview.zoomIn", "when": "previewFocus" }, + { "key": "mod+-", "command": "preview.zoomOut", "when": "previewFocus" }, + { "key": "mod+0", "command": "preview.resetZoom", "when": "previewFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, @@ -51,6 +57,12 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `preview.toggle`: open/close the in-app browser preview panel (desktop app only) +- `preview.refresh`: reload the active preview tab (in focused preview context by default) +- `preview.focusUrl`: focus the URL input of the preview panel (in focused preview context by default) +- `preview.zoomIn`: zoom the preview viewport in one step (in focused preview context by default) +- `preview.zoomOut`: zoom the preview viewport out one step (in focused preview context by default) +- `preview.resetZoom`: reset the preview zoom to 100% (in focused preview context by default) - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) @@ -80,6 +92,8 @@ Currently available context keys: - `terminalFocus` - `terminalOpen` +- `previewFocus` +- `previewOpen` Supported operators: diff --git a/infra/relay/README.md b/infra/relay/README.md index 697fa30cac0..114d5e9b07f 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -45,7 +45,7 @@ credential, or authorization behavior. Shared request and response schemas live in [`packages/contracts/src/relay.ts`](../../packages/contracts/src/relay.ts). Shared client-side relay calls live in -[`packages/client-runtime/src/managedRelay.ts`](../../packages/client-runtime/src/managedRelay.ts). +[`packages/client-runtime/src/relay/managedRelay.ts`](../../packages/client-runtime/src/relay/managedRelay.ts). ## Working Locally diff --git a/infra/relay/package.json b/infra/relay/package.json index 4fa7686e76e..213c1fe5cc8 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -9,7 +9,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@clerk/backend": "3.4.14", + "@clerk/backend": "3.6.1", "@effect/sql-pg": "catalog:", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index c62d1166962..de47eb9a49a 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -5,7 +5,7 @@ import { EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime"; +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { RelayCloudEnvironmentHealthProofPayload, RelayEnvironmentHealthResponse, diff --git a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts index fb0ca1c65f5..7494476e27e 100644 --- a/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts +++ b/oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.ts @@ -48,7 +48,7 @@ const LEGACY_BASELINE = new Map([ ["apps/web/src/cloud/dpop.test.ts", 2], ["apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts", 1], ["oxlint-plugin-t3code/rules/no-manual-effect-runtime-in-tests.test.ts", 7], - ["packages/client-runtime/src/managedRelayState.test.ts", 1], + ["packages/client-runtime/src/relay/managedRelayState.test.ts", 1], ["packages/client-runtime/src/wsTransport.test.ts", 2], ]); diff --git a/packages/client-runtime/README.md b/packages/client-runtime/README.md new file mode 100644 index 00000000000..722d6f6d389 --- /dev/null +++ b/packages/client-runtime/README.md @@ -0,0 +1,31 @@ +# Client Runtime + +Shared client behavior for web and mobile. Public APIs are organized by package +subpath. The package intentionally has no root export. + +## Public subpaths + +| Subpath | Responsibility | +| --------------------- | ---------------------------------------------------------------- | +| `authorization` | Bearer and DPoP authorization plus token persistence contracts | +| `connection` | Targets, catalog, supervision, retries, registry, and onboarding | +| `environment` | Environment identity, descriptors, endpoints, and scoped keys | +| `errors` | Shared client error inspection | +| `operations` | Multi-step application workflows | +| `operations/projects` | Multi-step project creation workflows | +| `platform` | Platform capability and persistence service contracts | +| `relay` | Managed relay API and environment discovery | +| `rpc` | HTTP/RPC clients, protocol, sessions, and subscriptions | +| `state/` | Focused shared state, retention, reducers, and Atom constructors | + +## Dependency direction + +Platform applications provide `platform` services. `connection` composes those +capabilities with `authorization`, `relay`, and `rpc` to supervise environment +sessions. Independent `state` modules consume the connection registry and expose +focused state or Atom constructors to application-owned runtimes. + +Applications should import the narrowest relevant subpath. There is no broad +`state` export: use domain paths such as `state/shell`, `state/threads`, +`state/terminal`, or `state/vcs`. Subpath indices and explicitly exported domain +files are public API boundaries; all other files remain implementation details. diff --git a/packages/client-runtime/STRUCTURE.md b/packages/client-runtime/STRUCTURE.md new file mode 100644 index 00000000000..8718245f268 --- /dev/null +++ b/packages/client-runtime/STRUCTURE.md @@ -0,0 +1,137 @@ +The current mistake is treating everything that depends on a connection as connection code. Dependency does not imply ownership. State, RPC transport, and authorization are separate concerns. + +Proposed Structure + +``` +src/ + connection/ + model.ts + errors.ts + catalog.ts + connectivity.ts + wakeups.ts + resolver.ts + driver.ts + supervisor.ts + registry.ts + onboarding.ts + presentation.ts + layer.ts + index.ts + + authorization/ + remote.ts + service.ts + tokenStore.ts + index.ts + + rpc/ + http.ts + protocol.ts + session.ts + client.ts + index.ts + + relay/ + managedRelay.ts + managedRelayState.ts + discovery.ts + index.ts + + state/ + runtime.ts + connections.ts + entities.ts + auth.ts + cloud.ts + shell.ts + threads.ts + terminal.ts + vcs.ts + filesystem.ts + projects.ts + review.ts + server.ts + sourceControl.ts + orchestration.ts + presentation.ts + session.ts + relayDiscovery.ts + + operations/ + projects.ts + commands.ts + + platform/ + capabilities.ts + persistence.ts + source.ts + storageDocument.ts + index.ts + + environment/ + knownEnvironment.ts + scoped.ts + descriptor.ts + endpoint.ts + + errors/ + errorTrace.ts + transport.ts +``` + +Boundaries + +connection/ owns only: + +Desired versus actual connection state. +Retry scheduling and wake-up signals. +Connection target catalog. +Opening, supervising, and closing sessions. +Platform-independent connection presentation state. +It must not know about threads, shell snapshots, terminals, VCS, or React atoms. + +rpc/ owns: + +WebSocket protocol. +Session lifecycle. +Typed RPC execution and subscriptions. +Readiness and close signals consumed by the connection driver. +authorization/ turns credentials and target metadata into authorized connection parameters. It does not supervise connections. + +relay/ owns the raw relay API, DPoP signer integration, and environment discovery. It does not own the connection state machine. + +state/ owns shared application data: + +Models, reducers, cache/retention behavior. +Effect services synchronizing RPC data into state. +Atom definitions and generic query/mutation/subscription bindings. +Simple RPC calls should use generic helpers from state/runtime.ts. We should not create a wrapper module for every RPC operation. operations/ is only for genuinely multi-step workflows. + +Critical Change + +The current registry must stop constructing an EnvironmentServices bundle containing shell, threads, commands, and RPC. That recreates the god-service problem indirectly. + +Instead: + +![alt text](structure.png) + +The registry exposes session availability and execution. Independent state modules consume it. Connection never imports state. + +Current Mapping + +connection/core/_ mostly becomes connection/_. +connection/transport/rpcSession.ts and remote/wsRpcProtocol.ts become rpc/_. +connection/transport/remoteAuthorization.ts becomes authorization/_. +Broker selection becomes connection/resolver.ts; bearer/DPoP implementation stays in authorization. +connection/services/threads.ts becomes state/threads.ts. +connection/services/authAccessSnapshot.ts becomes state/auth.ts. +connection/atoms/\* becomes domain files under state/. +Existing top-level shell, threads, terminal, and vcs state code merges into those state modules. +connection/services/runtime.ts should disappear rather than be relocated. +Public exports follow stable concerns such as `./connection`, `./rpc`, +`./authorization`, `./relay`, and individual `./state/threads` subpaths. There +is no root barrel, broad `./state` barrel, or public +application/core/services/transport implementation taxonomy. + +I would make this as one structural cut with all callers migrated, without compatibility facades. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index bf1c1bdc0c0..a1dd23c96ef 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -2,15 +2,130 @@ "name": "@t3tools/client-runtime", "private": true, "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", "exports": { - ".": { - "types": "./src/index.ts", - "react-native": "./src/index.ts", - "import": "./src/index.ts", - "require": "./src/index.ts", - "default": "./src/index.ts" + "./connection": { + "types": "./src/connection/index.ts", + "default": "./src/connection/index.ts" + }, + "./authorization": { + "types": "./src/authorization/index.ts", + "default": "./src/authorization/index.ts" + }, + "./environment": { + "types": "./src/environment/index.ts", + "default": "./src/environment/index.ts" + }, + "./errors": { + "types": "./src/errors/index.ts", + "default": "./src/errors/index.ts" + }, + "./rpc": { + "types": "./src/rpc/index.ts", + "default": "./src/rpc/index.ts" + }, + "./operations": { + "types": "./src/operations/index.ts", + "default": "./src/operations/index.ts" + }, + "./operations/projects": { + "types": "./src/operations/projects.ts", + "default": "./src/operations/projects.ts" + }, + "./platform": { + "types": "./src/platform/index.ts", + "default": "./src/platform/index.ts" + }, + "./relay": { + "types": "./src/relay/index.ts", + "default": "./src/relay/index.ts" + }, + "./state/auth": { + "types": "./src/state/auth.ts", + "default": "./src/state/auth.ts" + }, + "./state/assets": { + "types": "./src/state/assets.ts", + "default": "./src/state/assets.ts" + }, + "./state/cloud": { + "types": "./src/state/cloud.ts", + "default": "./src/state/cloud.ts" + }, + "./state/connections": { + "types": "./src/state/connections.ts", + "default": "./src/state/connections.ts" + }, + "./state/entities": { + "types": "./src/state/entities.ts", + "default": "./src/state/entities.ts" + }, + "./state/filesystem": { + "types": "./src/state/filesystem.ts", + "default": "./src/state/filesystem.ts" + }, + "./state/git": { + "types": "./src/state/git.ts", + "default": "./src/state/git.ts" + }, + "./state/models": { + "types": "./src/state/models.ts", + "default": "./src/state/models.ts" + }, + "./state/orchestration": { + "types": "./src/state/orchestration.ts", + "default": "./src/state/orchestration.ts" + }, + "./state/presentation": { + "types": "./src/state/presentation.ts", + "default": "./src/state/presentation.ts" + }, + "./state/preview": { + "types": "./src/state/preview.ts", + "default": "./src/state/preview.ts" + }, + "./state/projects": { + "types": "./src/state/projects.ts", + "default": "./src/state/projects.ts" + }, + "./state/relay": { + "types": "./src/state/relayDiscovery.ts", + "default": "./src/state/relayDiscovery.ts" + }, + "./state/review": { + "types": "./src/state/review.ts", + "default": "./src/state/review.ts" + }, + "./state/runtime": { + "types": "./src/state/runtime.ts", + "default": "./src/state/runtime.ts" + }, + "./state/server": { + "types": "./src/state/server.ts", + "default": "./src/state/server.ts" + }, + "./state/session": { + "types": "./src/state/session.ts", + "default": "./src/state/session.ts" + }, + "./state/shell": { + "types": "./src/state/shell.ts", + "default": "./src/state/shell.ts" + }, + "./state/source-control": { + "types": "./src/state/sourceControl.ts", + "default": "./src/state/sourceControl.ts" + }, + "./state/terminal": { + "types": "./src/state/terminal.ts", + "default": "./src/state/terminal.ts" + }, + "./state/threads": { + "types": "./src/state/threads.ts", + "default": "./src/state/threads.ts" + }, + "./state/vcs": { + "types": "./src/state/vcs.ts", + "default": "./src/state/vcs.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/advertisedEndpoint.ts b/packages/client-runtime/src/advertisedEndpoint.ts deleted file mode 100644 index da7d766fa80..00000000000 --- a/packages/client-runtime/src/advertisedEndpoint.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@t3tools/shared/advertisedEndpoint"; diff --git a/packages/client-runtime/src/archivedThreadsState.test.ts b/packages/client-runtime/src/archivedThreadsState.test.ts deleted file mode 100644 index 3a819fa30b9..00000000000 --- a/packages/client-runtime/src/archivedThreadsState.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type ArchivedThreadsClient, - createArchivedThreadsManager, - makeArchivedThreadsEnvironmentKey, - parseArchivedThreadsEnvironmentKey, - readArchivedThreadsSnapshotState, -} from "./archivedThreadsState.ts"; - -let registry = AtomRegistry.make(); - -function resetAtomRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -function createSnapshot(id: string): OrchestrationShellSnapshot { - return { - snapshotSequence: 1, - projects: [], - threads: [], - updatedAt: `2026-05-08T00:00:00.000Z`, - id, - } as OrchestrationShellSnapshot; -} - -describe("createArchivedThreadsManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads archived snapshots for configured environment clients", async () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const clients = new Map([ - [ - envA, - { - getArchivedShellSnapshot: vi.fn(async () => createSnapshot("a")), - }, - ], - [ - envB, - { - getArchivedShellSnapshot: vi.fn(async () => createSnapshot("b")), - }, - ], - ]); - const manager = createArchivedThreadsManager({ - getRegistry: () => registry, - getClient: (environmentId) => clients.get(environmentId) ?? null, - }); - - const result = registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envB, envA]))); - - await vi.waitFor(() => { - const state = readArchivedThreadsSnapshotState( - registry.get(manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB]))), - ); - expect(state.snapshots.map((snapshot) => snapshot.environmentId)).toEqual([envA, envB]); - }); - expect(readArchivedThreadsSnapshotState(result).isLoading).toBe(true); - }); - - it("refreshes known snapshot groups that include an environment", async () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const getArchivedShellSnapshot = vi.fn(async () => - createSnapshot(`a-${getArchivedShellSnapshot.mock.calls.length}`), - ); - const manager = createArchivedThreadsManager({ - getRegistry: () => registry, - getClient: (environmentId) => (environmentId === envA ? { getArchivedShellSnapshot } : null), - staleTimeMs: 60_000, - }); - - const atom = manager.getAtom(makeArchivedThreadsEnvironmentKey([envA, envB])); - registry.get(atom); - await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(1)); - - manager.refreshForEnvironment(envA); - - await vi.waitFor(() => expect(getArchivedShellSnapshot).toHaveBeenCalledTimes(2)); - }); - - it("round-trips environment keys in sorted order", () => { - const envA = EnvironmentId.make("env-a"); - const envB = EnvironmentId.make("env-b"); - const key = makeArchivedThreadsEnvironmentKey([envB, envA]); - - expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); - }); -}); diff --git a/packages/client-runtime/src/archivedThreadsState.ts b/packages/client-runtime/src/archivedThreadsState.ts deleted file mode 100644 index b1d6ec59e4e..00000000000 --- a/packages/client-runtime/src/archivedThreadsState.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import * as Cause from "effect/Cause"; -import * as Effect from "effect/Effect"; -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type ArchivedSnapshotEntry = { - readonly environmentId: EnvironmentId; - readonly snapshot: OrchestrationShellSnapshot; -}; - -export interface ArchivedThreadsClient { - readonly getArchivedShellSnapshot: () => Promise; -} - -export interface ArchivedThreadsSnapshotState { - readonly snapshots: ReadonlyArray; - readonly error: string | null; - readonly isLoading: boolean; -} - -const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; -const DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS = 5_000; -const DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS = 5 * 60_000; -const environmentIdOrder = Order.String as Order.Order; - -export function makeArchivedThreadsEnvironmentKey( - environmentIds: ReadonlyArray, -): string { - return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => - sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), - ); -} - -export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { - if (key.length === 0) { - return []; - } - return pipe( - key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), - Arr.map((environmentId) => EnvironmentId.make(environmentId)), - ); -} - -export function readArchivedThreadsSnapshotState( - result: AsyncResult.AsyncResult, unknown>, -): ArchivedThreadsSnapshotState { - const snapshots = Option.getOrElse(AsyncResult.value(result), () => []); - let error: string | null = null; - if (result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Failed to load archived threads."; - } - - return { - snapshots, - error, - isLoading: result.waiting, - }; -} - -export function createArchivedThreadsManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ArchivedThreadsClient | null; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -}) { - const knownEnvironmentKeys = new Set(); - const knownEnvironmentIdsByKey = new Map>(); - const staleTime = config.staleTimeMs ?? DEFAULT_ARCHIVED_THREADS_STALE_TIME_MS; - const idleTtl = config.idleTtlMs ?? DEFAULT_ARCHIVED_THREADS_IDLE_TTL_MS; - - const snapshotsAtom = Atom.family((environmentKey: string) => { - knownEnvironmentKeys.add(environmentKey); - knownEnvironmentIdsByKey.set( - environmentKey, - new Set(parseArchivedThreadsEnvironmentKey(environmentKey)), - ); - return Atom.make( - Effect.promise(async (): Promise> => { - const snapshots = await Promise.all( - pipe( - parseArchivedThreadsEnvironmentKey(environmentKey), - Arr.map(async (environmentId) => { - const client = config.getClient(environmentId); - if (!client) { - return null; - } - return { - environmentId, - snapshot: await client.getArchivedShellSnapshot(), - }; - }), - ), - ); - return pipe( - snapshots, - Arr.filterMap((snapshot) => - snapshot !== null ? Result.succeed(snapshot) : Result.failVoid, - ), - ); - }), - ).pipe( - Atom.swr({ - staleTime, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtl), - Atom.withLabel(`archived-thread-snapshots:${environmentKey}`), - ); - }); - - function getAtom(environmentKey: string) { - return snapshotsAtom(environmentKey); - } - - function refresh(environmentIds: ReadonlyArray): void { - config.getRegistry().refresh(getAtom(makeArchivedThreadsEnvironmentKey(environmentIds))); - } - - function refreshForEnvironment(environmentId: EnvironmentId): void { - for (const environmentKey of knownEnvironmentKeys) { - if (knownEnvironmentIdsByKey.get(environmentKey)?.has(environmentId)) { - config.getRegistry().refresh(getAtom(environmentKey)); - } - } - } - - return { - getAtom, - refresh, - refreshForEnvironment, - }; -} diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts new file mode 100644 index 00000000000..06137d1fd5c --- /dev/null +++ b/packages/client-runtime/src/authorization/index.ts @@ -0,0 +1,4 @@ +export * from "./layer.ts"; +export * from "./remote.ts"; +export * from "./service.ts"; +export * from "./tokenStore.ts"; diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts new file mode 100644 index 00000000000..b65eacaa794 --- /dev/null +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -0,0 +1,344 @@ +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +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 { ManagedRelayDpopSigner, ManagedRelayDpopSignerError } from "../relay/managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { ClientPresentation } from "../platform/capabilities.ts"; +import { RemoteEnvironmentAuthorization, type RelayEnvironmentAuthorization } from "./service.ts"; +import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; +import { remoteEnvironmentAuthorizationLayer } from "./layer.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const ENDPOINT = { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel" as const, +}; +const DESCRIPTOR = { + environmentId: ENVIRONMENT_ID, + label: "Remote environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, +}; +const BOOTSTRAP: RelayEnvironmentAuthorization = { + environmentId: ENVIRONMENT_ID, + endpoint: ENDPOINT, + credential: "relay-bootstrap", +}; + +function recordedFetch(responses: ReadonlyArray) { + const calls: Array = []; + let responseIndex = 0; + const fetchFn = ((input, init) => { + calls.push([input, init ?? {}]); + const response = responses[responseIndex++]; + return response === undefined + ? Promise.reject(new Error(`Unexpected fetch call to ${String(input)}`)) + : Promise.resolve(response); + }) satisfies typeof fetch; + return { calls, fetchFn }; +} + +const websocketTicket = (ticket: string) => + Response.json({ + ticket, + expiresAt: "2026-06-06T01:00:00.000Z", + }); + +const accessToken = (token: string) => + Response.json({ + access_token: token, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 3_600, + scope: AuthStandardClientScopes.join(" "), + }); + +const authInvalid = () => + Response.json( + { + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-auth-invalid", + }, + { status: 401 }, + ); + +const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* (input: { + readonly initialToken?: RemoteDpopAccessToken; + readonly responses: ReadonlyArray; +}) { + const tokens = yield* Ref.make( + new Map( + input.initialToken === undefined + ? [] + : [[input.initialToken.environmentId, input.initialToken]], + ), + ); + const bootstrapCalls = yield* Ref.make(0); + const proofInputs = yield* Ref.make< + ReadonlyArray<{ + readonly method: string; + readonly url: string; + readonly accessToken?: string; + }> + >([]); + const fetch = recordedFetch(input.responses); + + const tokenStore = RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + Ref.get(tokens).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + ), + put: (token) => + Ref.update(tokens, (current) => { + const next = new Map(current); + next.set(token.environmentId, token); + return next; + }), + remove: (environmentId) => + Ref.update(tokens, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + }); + const signer = ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("thumbprint-1"), + createProof: (proofInput) => + Ref.update(proofInputs, (current) => [...current, proofInput]).pipe( + Effect.as(`proof:${proofInput.url}`), + Effect.mapError((cause) => new ManagedRelayDpopSignerError({ cause })), + ), + }); + const layer = remoteEnvironmentAuthorizationLayer.pipe( + Layer.provide( + Layer.mergeAll( + remoteHttpClientLayer(fetch.fetchFn), + Layer.succeed(ManagedRelayDpopSigner, signer), + Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed( + ClientPresentation, + ClientPresentation.of({ + metadata: { + label: "T3 Code Test", + deviceType: "mobile", + os: "test", + }, + scopes: AuthStandardClientScopes, + }), + ), + ), + ), + ); + const obtainBootstrap = Ref.update(bootstrapCalls, (count) => count + 1).pipe( + Effect.as(BOOTSTRAP), + ); + + return { + layer, + tokens, + bootstrapCalls, + proofInputs, + fetch, + obtainBootstrap, + }; +}); + +describe("RemoteEnvironmentAuthorization", () => { + it.effect("reuses a valid persisted environment token without contacting the relay", () => + Effect.gen(function* () { + const cached = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "cached-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [websocketTicket("cached-ticket")], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=cached-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + expect(harness.fetch.calls).toHaveLength(1); + expect(String(harness.fetch.calls[0]?.[0])).toBe( + "https://environment.example.test/api/auth/websocket-ticket", + ); + }), + ); + + it.effect("refreshes and persists an expired environment token", () => + Effect.gen(function* () { + const expired = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "expired-access-token", + expiresAtEpochMs: 0, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: expired, + responses: [ + Response.json(DESCRIPTOR), + accessToken("fresh-access-token"), + websocketTicket("fresh-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=fresh-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "fresh-access-token", + dpopThumbprint: "thumbprint-1", + }), + ); + expect(harness.fetch.calls).toHaveLength(3); + }), + ); + + it.effect("evicts an auth-invalid cached token and obtains a fresh bootstrap", () => + Effect.gen(function* () { + const cached = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "invalid-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [ + authInvalid(), + Response.json(DESCRIPTOR), + accessToken("replacement-access-token"), + websocketTicket("replacement-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "replacement-access-token", + }), + ); + expect(harness.fetch.calls).toHaveLength(4); + }), + ); + + it.effect("refreshes a cached endpoint after consecutive transient failures", () => + Effect.gen(function* () { + const cached = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: DESCRIPTOR.label, + endpoint: ENDPOINT, + accessToken: "cached-access-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint-1", + }); + const harness = yield* makeHarness({ + initialToken: cached, + responses: [ + new Response("endpoint unavailable", { status: 503 }), + new Response("endpoint still unavailable", { status: 503 }), + Response.json(DESCRIPTOR), + accessToken("replacement-access-token"), + websocketTicket("replacement-ticket"), + ], + }); + + const authorized = yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + const firstFailure = yield* remote + .authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }) + .pipe(Effect.flip); + + expect(firstFailure._tag).toBe("ConnectionTransientError"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(0); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toBe(cached); + + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer)); + + expect(authorized.socketUrl).toContain("wsTicket=replacement-ticket"); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect((yield* Ref.get(harness.tokens)).get(ENVIRONMENT_ID)).toEqual( + expect.objectContaining({ + accessToken: "replacement-access-token", + }), + ); + expect(harness.fetch.calls).toHaveLength(5); + }), + ); + + it.effect("does not persist a refreshed token until its websocket ticket succeeds", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + responses: [ + Response.json(DESCRIPTOR), + accessToken("unusable-access-token"), + new Response("endpoint unavailable", { status: 503 }), + ], + }); + + yield* Effect.gen(function* () { + const remote = yield* RemoteEnvironmentAuthorization; + return yield* remote.authorizeDpop({ + expectedEnvironmentId: ENVIRONMENT_ID, + obtainBootstrap: harness.obtainBootstrap, + }); + }).pipe(Effect.provide(harness.layer), Effect.flip); + + expect((yield* Ref.get(harness.tokens)).has(ENVIRONMENT_ID)).toBe(false); + expect(yield* Ref.get(harness.bootstrapCalls)).toBe(1); + expect(harness.fetch.calls).toHaveLength(3); + }), + ); +}); diff --git a/packages/client-runtime/src/authorization/layer.ts b/packages/client-runtime/src/authorization/layer.ts new file mode 100644 index 00000000000..9b71edf0461 --- /dev/null +++ b/packages/client-runtime/src/authorization/layer.ts @@ -0,0 +1,268 @@ +import { + exchangeRemoteDpopAccessToken, + type RemoteEnvironmentAuthError, + resolveRemoteDpopWebSocketConnectionUrl, + resolveRemoteWebSocketConnectionUrl, +} from "./remote.ts"; +import { environmentMismatchError, mapRemoteEnvironmentError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { ClientPresentation } from "../platform/capabilities.ts"; +import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { RemoteEnvironmentAuthorization } from "./service.ts"; +import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "./tokenStore.ts"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; + +const TOKEN_EXPIRY_SAFETY_MARGIN_MS = 60_000; +const CACHED_ENDPOINT_FAILURE_THRESHOLD = 2; + +function mapDpopSocketError(error: RemoteEnvironmentAuthError | ConnectionAttemptError) { + return error._tag === "ConnectionTransientError" || error._tag === "ConnectionBlockedError" + ? error + : mapRemoteEnvironmentError(error); +} + +const fetchDescriptor = Effect.fn("clientRuntime.connection.remote.fetchDescriptor")(function* ( + httpBaseUrl: string, +) { + return yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + ); +}); + +export const remoteEnvironmentAuthorizationLayer = Layer.effect( + RemoteEnvironmentAuthorization, + Effect.gen(function* () { + const signer = yield* ManagedRelayDpopSigner; + const presentation = yield* ClientPresentation; + const tokenStore = yield* RemoteDpopAccessTokenStore; + const httpClient = yield* HttpClient.HttpClient; + const cachedEndpointFailures = yield* Ref.make>(new Map()); + + const resetCachedEndpointFailures = (environmentId: string) => + Ref.update(cachedEndpointFailures, (current) => { + if (!current.has(environmentId)) { + return current; + } + const next = new Map(current); + next.delete(environmentId); + return next; + }); + + const recordCachedEndpointFailure = (environmentId: string) => + Ref.modify(cachedEndpointFailures, (current) => { + const failureCount = (current.get(environmentId) ?? 0) + 1; + const next = new Map(current); + next.set(environmentId, failureCount); + return [failureCount, next] as const; + }); + + const authorizeBearer = Effect.fn("clientRuntime.connection.remote.authorizeBearer")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeBearer"] + >[0]["expectedEnvironmentId"]; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) { + const descriptor = yield* fetchDescriptor(input.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const socketUrl = yield* resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: input.wsBaseUrl, + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: input.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }; + }, + ); + + const createDpopSocketUrl = Effect.fn("clientRuntime.connection.remote.createDpopSocketUrl")( + function* (token: RemoteDpopAccessToken) { + const ticketProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(token.endpoint.httpBaseUrl, "/api/auth/websocket-ticket"), + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + message: "Could not create the websocket authorization proof.", + }), + ), + ); + return yield* resolveRemoteDpopWebSocketConnectionUrl({ + wsBaseUrl: token.endpoint.wsBaseUrl, + httpBaseUrl: token.endpoint.httpBaseUrl, + accessToken: token.accessToken, + dpopProof: ticketProof, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + }, + ); + + const authorizeDpop = Effect.fn("clientRuntime.connection.remote.authorizeDpop")( + function* (input: { + readonly expectedEnvironmentId: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["expectedEnvironmentId"]; + readonly obtainBootstrap: Parameters< + RemoteEnvironmentAuthorization["Service"]["authorizeDpop"] + >[0]["obtainBootstrap"]; + }) { + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + message: "Could not load the environment authorization key.", + }), + ), + Effect.withSpan("environment.authorization.dpopKey.resolve"), + ); + const now = yield* Clock.currentTimeMillis; + const cached = yield* tokenStore + .get(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.cache")); + if ( + Option.isSome(cached) && + cached.value.environmentId === input.expectedEnvironmentId && + cached.value.dpopThumbprint === thumbprint && + cached.value.expiresAtEpochMs > now + TOKEN_EXPIRY_SAFETY_MARGIN_MS + ) { + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "hit", + }); + const cachedSocket = yield* createDpopSocketUrl(cached.value).pipe(Effect.result); + if (Result.isSuccess(cachedSocket)) { + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + return { + environmentId: cached.value.environmentId, + label: cached.value.label, + httpBaseUrl: cached.value.endpoint.httpBaseUrl, + socketUrl: cachedSocket.success, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: cached.value.accessToken, + }, + }; + } + if (cachedSocket.failure._tag === "ConnectionBlockedError") { + return yield* mapDpopSocketError(cachedSocket.failure); + } + const mappedFailure = mapDpopSocketError(cachedSocket.failure); + if (mappedFailure._tag === "ConnectionTransientError") { + const failureCount = yield* recordCachedEndpointFailure(input.expectedEnvironmentId); + if (failureCount < CACHED_ENDPOINT_FAILURE_THRESHOLD) { + return yield* mappedFailure; + } + } + yield* tokenStore + .remove(input.expectedEnvironmentId) + .pipe(Effect.withSpan("environment.authorization.accessToken.remove")); + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + } + + yield* resetCachedEndpointFailures(input.expectedEnvironmentId); + yield* Effect.annotateCurrentSpan({ + "connection.remote_token_cache": "miss", + }); + const bootstrap = yield* input.obtainBootstrap; + const descriptor = yield* fetchDescriptor(bootstrap.endpoint.httpBaseUrl).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.descriptor"), + ); + if (descriptor.environmentId !== input.expectedEnvironmentId) { + return yield* environmentMismatchError({ + expected: input.expectedEnvironmentId, + actual: descriptor.environmentId, + }); + } + const bootstrapProof = yield* signer + .createProof({ + method: "POST", + url: environmentEndpointUrl(bootstrap.endpoint.httpBaseUrl, "/oauth/token"), + }) + .pipe( + Effect.mapError( + () => + new ConnectionBlockedError({ + reason: "configuration", + message: "Could not create the environment authorization proof.", + }), + ), + ); + const access = yield* exchangeRemoteDpopAccessToken({ + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + credential: bootstrap.credential, + dpopProof: bootstrapProof, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }).pipe( + Effect.mapError(mapRemoteEnvironmentError), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.withSpan("environment.authorization.accessToken.exchange"), + ); + const issuedAt = yield* Clock.currentTimeMillis; + const token = new RemoteDpopAccessToken({ + environmentId: descriptor.environmentId, + label: descriptor.label, + endpoint: bootstrap.endpoint, + accessToken: access.access_token, + expiresAtEpochMs: issuedAt + access.expires_in * 1_000, + dpopThumbprint: thumbprint, + }); + const socketUrl = yield* createDpopSocketUrl(token).pipe( + Effect.mapError(mapDpopSocketError), + ); + yield* tokenStore + .put(token) + .pipe(Effect.withSpan("environment.authorization.accessToken.persist")); + return { + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: bootstrap.endpoint.httpBaseUrl, + socketUrl, + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: token.accessToken, + }, + }; + }, + ); + + return RemoteEnvironmentAuthorization.of({ + authorizeBearer, + authorizeDpop: (input) => + authorizeDpop(input).pipe(Effect.withSpan("environment.authorization")), + }); + }), +); diff --git a/packages/client-runtime/src/remote.test.ts b/packages/client-runtime/src/authorization/remote.test.ts similarity index 97% rename from packages/client-runtime/src/remote.test.ts rename to packages/client-runtime/src/authorization/remote.test.ts index c20832bd37e..6e6ccc86052 100644 --- a/packages/client-runtime/src/remote.test.ts +++ b/packages/client-runtime/src/authorization/remote.test.ts @@ -10,15 +10,15 @@ import { bootstrapRemoteBearerSession, exchangeRemoteDpopAccessToken, fetchRemoteDpopSessionState, - fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, issueRemoteDpopWebSocketTicket, issueRemoteWebSocketTicket, - remoteHttpClientLayer, RemoteEnvironmentAuthInvalidJsonError, RemoteEnvironmentAuthTimeoutError, resolveRemoteWebSocketConnectionUrl, } from "./remote.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); @@ -88,7 +88,7 @@ const expectFetchCall = ( } }; -describe("remote", () => { +describe("remote environment authorization", () => { it.effect("bootstraps bearer auth against a remote backend", () => Effect.gen(function* () { const fetch = recordedFetch( @@ -391,7 +391,7 @@ describe("remote", () => { expect(error).toBeInstanceOf(RemoteEnvironmentAuthTimeoutError); expect(error.message).toBe( - "Remote auth endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", + "Remote environment endpoint http://remote.example.com/.well-known/t3/environment timed out after 25ms.", ); }).pipe(Effect.provide(TestClock.layer())), ); @@ -446,7 +446,7 @@ describe("remote", () => { expect(error).toBeInstanceOf(RemoteEnvironmentAuthInvalidJsonError); expect(error.message).toBe( - "Remote auth endpoint returned an invalid response from https://remote.example.com/oauth/token.", + "Remote environment endpoint returned an invalid response from https://remote.example.com/oauth/token.", ); }), ); diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts new file mode 100644 index 00000000000..69c157d0e50 --- /dev/null +++ b/packages/client-runtime/src/authorization/remote.ts @@ -0,0 +1,214 @@ +import { + AuthAccessTokenType, + type AuthClientPresentationMetadata, + AuthEnvironmentBootstrapTokenType, + AuthTokenExchangeGrantType, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; +import * as Effect from "effect/Effect"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import { + executeEnvironmentHttpRequest, + makeEnvironmentHttpApiClient, + type RemoteEnvironmentRequestError, +} from "../rpc/http.ts"; + +export { + RemoteEnvironmentAuthFetchError, + RemoteEnvironmentAuthInvalidJsonError, + RemoteEnvironmentAuthTimeoutError, + RemoteEnvironmentAuthUndeclaredStatusError, +} from "../rpc/http.ts"; +export type RemoteEnvironmentAuthError = RemoteEnvironmentRequestError; + +const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; + +const clientMetadataTokenExchangeFields = ( + clientMetadata: AuthClientPresentationMetadata | undefined, +) => ({ + ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), + ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), + ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), +}); + +export const exchangeRemoteDpopAccessToken = Effect.fn( + "clientRuntime.authorization.exchangeRemoteDpopAccessToken", +)(function* (input: { + readonly httpBaseUrl: string; + readonly credential: string; + readonly scopes?: ReadonlyArray; + readonly clientMetadata?: AuthClientPresentationMetadata; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const response = yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.token({ + headers: { dpop: input.dpopProof }, + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), + }, + }), + ); + return response; +}); + +export const bootstrapRemoteBearerSession = Effect.fn( + "clientRuntime.authorization.bootstrapRemoteBearerSession", +)(function* (input: { + readonly httpBaseUrl: string; + readonly credential: string; + readonly scopes?: ReadonlyArray; + readonly clientMetadata?: AuthClientPresentationMetadata; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.token({ + headers: {}, + payload: { + grant_type: AuthTokenExchangeGrantType, + subject_token: input.credential, + subject_token_type: AuthEnvironmentBootstrapTokenType, + requested_token_type: AuthAccessTokenType, + ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), + ...clientMetadataTokenExchangeFields(input.clientMetadata), + }, + }), + ); +}); + +export const fetchRemoteSessionState = Effect.fn( + "clientRuntime.authorization.fetchRemoteSessionState", +)(function* (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.session({ + headers: { + authorization: `Bearer ${input.bearerToken}`, + }, + }), + ); +}); + +export const fetchRemoteDpopSessionState = Effect.fn( + "clientRuntime.authorization.fetchRemoteDpopSessionState", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.session({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + +export const issueRemoteWebSocketTicket = Effect.fn( + "clientRuntime.authorization.issueRemoteWebSocketTicket", +)(function* (input: { + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.webSocketTicket({ + headers: { + authorization: `Bearer ${input.bearerToken}`, + }, + }), + ); +}); + +export const issueRemoteDpopWebSocketTicket = Effect.fn( + "clientRuntime.authorization.issueRemoteDpopWebSocketTicket", +)(function* (input: { + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.auth.webSocketTicket({ + headers: { + authorization: `DPoP ${input.accessToken}`, + dpop: input.dpopProof, + }, + }), + ); +}); + +export const resolveRemoteWebSocketConnectionUrl = Effect.fn( + "clientRuntime.authorization.resolveRemoteWebSocketConnectionUrl", +)(function* (input: { + readonly wsBaseUrl: string; + readonly httpBaseUrl: string; + readonly bearerToken: string; + readonly timeoutMs?: number; +}) { + const issued = yield* issueRemoteWebSocketTicket({ + httpBaseUrl: input.httpBaseUrl, + bearerToken: input.bearerToken, + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + + const url = new URL(input.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + url.searchParams.set("wsTicket", issued.ticket); + return url.toString(); +}); + +export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( + "clientRuntime.authorization.resolveRemoteDpopWebSocketConnectionUrl", +)(function* (input: { + readonly wsBaseUrl: string; + readonly httpBaseUrl: string; + readonly accessToken: string; + readonly dpopProof: string; + readonly timeoutMs?: number; +}) { + const issued = yield* issueRemoteDpopWebSocketTicket({ + httpBaseUrl: input.httpBaseUrl, + accessToken: input.accessToken, + dpopProof: input.dpopProof, + ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), + }); + const url = new URL(input.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + url.searchParams.set("wsTicket", issued.ticket); + return url.toString(); +}); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts new file mode 100644 index 00000000000..2a39edfd074 --- /dev/null +++ b/packages/client-runtime/src/authorization/service.ts @@ -0,0 +1,39 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; + +import type { ConnectionAttemptError, PreparedHttpAuthorization } from "../connection/model.ts"; + +export interface RelayEnvironmentAuthorization { + readonly environmentId: EnvironmentId; + readonly endpoint: RelayManagedEndpoint; + readonly credential: string; +} + +export interface AuthorizedRemoteEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + readonly socketUrl: string; + readonly httpAuthorization: PreparedHttpAuthorization; +} + +export class RemoteEnvironmentAuthorization extends Context.Service< + RemoteEnvironmentAuthorization, + { + readonly authorizeBearer: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly bearerToken: string; + }) => Effect.Effect; + readonly authorizeDpop: (input: { + readonly expectedEnvironmentId: EnvironmentId; + readonly obtainBootstrap: Effect.Effect< + RelayEnvironmentAuthorization, + ConnectionAttemptError + >; + }) => Effect.Effect; + } +>()("@t3tools/client-runtime/authorization/service/RemoteEnvironmentAuthorization") {} diff --git a/packages/client-runtime/src/authorization/tokenStore.ts b/packages/client-runtime/src/authorization/tokenStore.ts new file mode 100644 index 00000000000..e00cc4cfdff --- /dev/null +++ b/packages/client-runtime/src/authorization/tokenStore.ts @@ -0,0 +1,30 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayManagedEndpoint } from "@t3tools/contracts/relay"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; + +export class RemoteDpopAccessToken extends Schema.Class( + "@t3tools/client-runtime/authorization/RemoteDpopAccessToken", +)({ + environmentId: EnvironmentId, + label: Schema.String, + endpoint: RelayManagedEndpoint, + accessToken: Schema.String, + expiresAtEpochMs: Schema.Number, + dpopThumbprint: Schema.String, +}) {} + +export class RemoteDpopAccessTokenStore extends Context.Service< + RemoteDpopAccessTokenStore, + { + readonly get: ( + environmentId: EnvironmentId, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (token: RemoteDpopAccessToken) => Effect.Effect; + readonly remove: (environmentId: EnvironmentId) => Effect.Effect; + } +>()("@t3tools/client-runtime/authorization/tokenStore/RemoteDpopAccessTokenStore") {} diff --git a/packages/client-runtime/src/checkpointDiffState.test.ts b/packages/client-runtime/src/checkpointDiffState.test.ts deleted file mode 100644 index c5fa51e3d36..00000000000 --- a/packages/client-runtime/src/checkpointDiffState.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { EnvironmentId, ThreadId, type OrchestrationGetTurnDiffResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type CheckpointDiffClient, - createCheckpointDiffManager, - EMPTY_CHECKPOINT_DIFF_STATE, - getCheckpointDiffTargetKey, -} from "./checkpointDiffState.ts"; - -let registry = AtomRegistry.make(); - -function resetAtomRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), - fromTurnCount: 1, - toTurnCount: 2, - ignoreWhitespace: false, -}; - -const PATCH_RESULT: OrchestrationGetTurnDiffResult = { - threadId: TARGET.threadId, - diff: "patch", - fromTurnCount: 1, - toTurnCount: 2, -}; - -function createClient() { - return { - getTurnDiff: vi.fn(async () => PATCH_RESULT), - getFullThreadDiff: vi.fn(async () => PATCH_RESULT), - } satisfies CheckpointDiffClient; -} - -describe("createCheckpointDiffManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads a turn checkpoint diff into atom state", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); - - expect(client.getTurnDiff).toHaveBeenCalledWith({ - threadId: TARGET.threadId, - fromTurnCount: 1, - toTurnCount: 2, - ignoreWhitespace: false, - }); - expect(client.getFullThreadDiff).not.toHaveBeenCalled(); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: PATCH_RESULT, - error: null, - isPending: false, - }); - }); - - it("loads a full thread diff when the range starts at zero", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - await manager.load({ ...TARGET, fromTurnCount: 0 }); - - expect(client.getFullThreadDiff).toHaveBeenCalledWith({ - threadId: TARGET.threadId, - toTurnCount: 2, - ignoreWhitespace: false, - }); - expect(client.getTurnDiff).not.toHaveBeenCalled(); - }); - - it("returns empty state for invalid targets", () => { - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => createClient(), - }); - - expect(manager.getSnapshot({ ...TARGET, threadId: null })).toBe(EMPTY_CHECKPOINT_DIFF_STATE); - expect(getCheckpointDiffTargetKey({ ...TARGET, threadId: null })).toBeNull(); - }); - - it("deduplicates in-flight requests and reuses successful cached data", async () => { - const client = createClient(); - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - }); - - const first = manager.load(TARGET); - const second = manager.load(TARGET); - - expect(first).toBe(second); - await first; - await manager.load(TARGET); - - expect(client.getTurnDiff).toHaveBeenCalledTimes(1); - }); - - it("retries temporarily unavailable checkpoint diffs", async () => { - let attempts = 0; - const client = { - getFullThreadDiff: vi.fn(async () => PATCH_RESULT), - getTurnDiff: vi.fn(async () => { - attempts += 1; - if (attempts < 3) { - throw new Error("checkpoint is unavailable for turn"); - } - return PATCH_RESULT; - }), - } satisfies CheckpointDiffClient; - const manager = createCheckpointDiffManager({ - getRegistry: () => registry, - getClient: () => client, - retryDelay: async () => undefined, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(PATCH_RESULT); - - expect(client.getTurnDiff).toHaveBeenCalledTimes(3); - }); -}); diff --git a/packages/client-runtime/src/checkpointDiffState.ts b/packages/client-runtime/src/checkpointDiffState.ts deleted file mode 100644 index b0752584bc6..00000000000 --- a/packages/client-runtime/src/checkpointDiffState.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { - type EnvironmentId, - OrchestrationGetFullThreadDiffInput, - type OrchestrationGetFullThreadDiffResult, - OrchestrationGetTurnDiffInput, - type OrchestrationGetTurnDiffResult, - type ThreadId, -} from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type CheckpointDiffResult = - | OrchestrationGetTurnDiffResult - | OrchestrationGetFullThreadDiffResult; - -export interface CheckpointDiffState { - readonly data: CheckpointDiffResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface CheckpointDiffTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; - readonly fromTurnCount: number | null; - readonly toTurnCount: number | null; - readonly ignoreWhitespace: boolean; - readonly cacheScope?: string | null; -} - -export interface CheckpointDiffClient { - readonly getTurnDiff: ( - input: OrchestrationGetTurnDiffInput, - ) => Promise; - readonly getFullThreadDiff: ( - input: OrchestrationGetFullThreadDiffInput, - ) => Promise; -} - -export const EMPTY_CHECKPOINT_DIFF_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_CHECKPOINT_DIFF_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownCheckpointDiffKeys = new Set(); - -export const checkpointDiffStateAtom = Atom.family((key: string) => { - knownCheckpointDiffKeys.add(key); - return Atom.make(INITIAL_CHECKPOINT_DIFF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`checkpoint-diff:${key}`), - ); -}); - -export const EMPTY_CHECKPOINT_DIFF_ATOM = Atom.make(EMPTY_CHECKPOINT_DIFF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("checkpoint-diff:null"), -); - -const decodeFullThreadDiffInput = Schema.decodeUnknownOption(OrchestrationGetFullThreadDiffInput); -const decodeTurnDiffInput = Schema.decodeUnknownOption(OrchestrationGetTurnDiffInput); - -type CheckpointDiffRequest = - | { - readonly kind: "fullThreadDiff"; - readonly input: OrchestrationGetFullThreadDiffInput; - } - | { - readonly kind: "turnDiff"; - readonly input: OrchestrationGetTurnDiffInput; - }; - -export function getCheckpointDiffTargetKey(target: CheckpointDiffTarget): string | null { - const decoded = decodeCheckpointDiffRequest(target); - if (target.environmentId === null || decoded._tag === "None") { - return null; - } - - return [ - target.environmentId, - target.threadId, - target.fromTurnCount, - target.toTurnCount, - target.ignoreWhitespace, - target.cacheScope ?? null, - ].join(":"); -} - -function decodeCheckpointDiffRequest(target: CheckpointDiffTarget) { - if (target.fromTurnCount === 0) { - return decodeFullThreadDiffInput({ - threadId: target.threadId, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - }).pipe(Option.map((input) => ({ kind: "fullThreadDiff" as const, input }))); - } - - return decodeTurnDiffInput({ - threadId: target.threadId, - fromTurnCount: target.fromTurnCount, - toTurnCount: target.toTurnCount, - ignoreWhitespace: target.ignoreWhitespace, - }).pipe(Option.map((input) => ({ kind: "turnDiff" as const, input }))); -} - -function asCheckpointErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - if (typeof error === "string") { - return error; - } - return ""; -} - -export function normalizeCheckpointDiffErrorMessage(error: unknown): string { - const message = asCheckpointErrorMessage(error).trim(); - if (message.length === 0) { - return "Failed to load checkpoint diff."; - } - - const lower = message.toLowerCase(); - if (lower.includes("not a git repository")) { - return "Turn diffs are unavailable because this project is not a git repository."; - } - - if ( - lower.includes("checkpoint unavailable for thread") || - lower.includes("checkpoint invariant violation") - ) { - const separatorIndex = message.indexOf(":"); - if (separatorIndex >= 0) { - const detail = message.slice(separatorIndex + 1).trim(); - if (detail.length > 0) { - return detail; - } - } - } - - return message; -} - -function isCheckpointTemporarilyUnavailable(error: unknown): boolean { - const message = asCheckpointErrorMessage(error).toLowerCase(); - return ( - message.includes("exceeds current turn count") || - message.includes("checkpoint is unavailable for turn") || - message.includes("filesystem checkpoint is unavailable") - ); -} - -function defaultRetryDelay(attempt: number, error: unknown): Promise { - const delayMs = isCheckpointTemporarilyUnavailable(error) - ? Math.min(5_000, 250 * 2 ** (attempt - 1)) - : Math.min(1_000, 100 * 2 ** (attempt - 1)); - return Effect.runPromise(Effect.sleep(Duration.millis(delayMs))); -} - -export function createCheckpointDiffManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => CheckpointDiffClient | null; - readonly retryDelay?: (attempt: number, error: unknown) => Promise; -}) { - const inFlight = new Map>(); - const versions = new Map(); - - function getVersion(targetKey: string): number { - return versions.get(targetKey) ?? 0; - } - - function bumpVersion(targetKey: string): void { - versions.set(targetKey, getVersion(targetKey) + 1); - } - - function setState(targetKey: string, state: CheckpointDiffState): void { - config.getRegistry().set(checkpointDiffStateAtom(targetKey), state); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - setState( - targetKey, - current.data === null ? INITIAL_CHECKPOINT_DIFF_STATE : { ...current, isPending: true }, - ); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: normalizeCheckpointDiffErrorMessage(error), - isPending: false, - }); - } - - async function requestWithRetry( - client: CheckpointDiffClient, - request: CheckpointDiffRequest, - ): Promise { - let attempt = 0; - while (true) { - attempt += 1; - try { - if (request.kind === "fullThreadDiff") { - return await client.getFullThreadDiff(request.input); - } - return await client.getTurnDiff(request.input); - } catch (error) { - const maxAttempts = isCheckpointTemporarilyUnavailable(error) ? 13 : 4; - if (attempt >= maxAttempts) { - throw error; - } - await (config.retryDelay ?? defaultRetryDelay)(attempt, error); - } - } - } - - function load( - target: CheckpointDiffTarget, - client?: CheckpointDiffClient, - options?: { readonly force?: boolean }, - ): Promise { - const targetKey = getCheckpointDiffTargetKey(target); - const decoded = decodeCheckpointDiffRequest(target); - if (targetKey === null || target.environmentId === null || decoded._tag === "None") { - return Promise.resolve(null); - } - - if (!options?.force) { - const current = config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - if (current.data !== null && current.error === null) { - return Promise.resolve(current.data); - } - } - - const existing = inFlight.get(targetKey); - if (existing) { - return existing; - } - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - setError(targetKey, new Error("Remote connection is not ready.")); - return Promise.resolve(config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data); - } - - markPending(targetKey); - const version = getVersion(targetKey); - const promise = requestWithRetry(resolved, decoded.value).then( - (result) => { - if (getVersion(targetKey) === version) { - setState(targetKey, { data: result, error: null, isPending: false }); - } - return result; - }, - (error: unknown) => { - if (getVersion(targetKey) === version) { - setError(targetKey, error); - } - return config.getRegistry().get(checkpointDiffStateAtom(targetKey)).data; - }, - ); - inFlight.set(targetKey, promise); - void promise.finally(() => { - if (inFlight.get(targetKey) === promise) { - inFlight.delete(targetKey); - } - }); - return promise; - } - - function getSnapshot(target: CheckpointDiffTarget): CheckpointDiffState { - const targetKey = getCheckpointDiffTargetKey(target); - return targetKey === null - ? EMPTY_CHECKPOINT_DIFF_STATE - : config.getRegistry().get(checkpointDiffStateAtom(targetKey)); - } - - function invalidate(target?: CheckpointDiffTarget): void { - if (target) { - const targetKey = getCheckpointDiffTargetKey(target); - if (targetKey === null) { - return; - } - bumpVersion(targetKey); - inFlight.delete(targetKey); - setState(targetKey, INITIAL_CHECKPOINT_DIFF_STATE); - return; - } - - for (const key of knownCheckpointDiffKeys) { - bumpVersion(key); - setState(key, INITIAL_CHECKPOINT_DIFF_STATE); - } - inFlight.clear(); - } - - return { - getSnapshot, - invalidate, - load, - }; -} diff --git a/packages/client-runtime/src/composerPathSearchState.test.ts b/packages/client-runtime/src/composerPathSearchState.test.ts deleted file mode 100644 index 8e5c739ba5d..00000000000 --- a/packages/client-runtime/src/composerPathSearchState.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { assert, beforeEach, it, vi } from "vite-plus/test"; -import type { EnvironmentId } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - type ComposerPathSearchClient, - createComposerPathSearchManager, - EMPTY_COMPOSER_PATH_SEARCH_STATE, - getComposerPathSearchTargetKey, -} from "./composerPathSearchState.ts"; - -let registry = AtomRegistry.make(); - -const noop = () => undefined; - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -const TARGET = { - environmentId: "env-local" as EnvironmentId, - cwd: "/repo", - query: "src", -}; - -it("derives null keys for inactive path searches", () => { - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, query: "" }), null); - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, cwd: null }), null); - assert.strictEqual(getComposerPathSearchTargetKey({ ...TARGET, environmentId: null }), null); -}); - -it("stores path search results in atom state", async () => { - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - getClient: () => ({ - searchEntries: async () => ({ - entries: [ - { path: "src/index.ts", kind: "file" }, - { path: "src/components", kind: "directory" }, - ], - truncated: false, - }), - }), - }); - - manager.search(TARGET); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [ - { path: "src/index.ts", kind: "file" }, - { path: "src/components", kind: "directory" }, - ], - isPending: false, - error: null, - }); -}); - -it("reuses fresh cached path search results", async () => { - const searchEntries = vi.fn(async () => ({ - entries: [{ path: "src/index.ts", kind: "file" as const }], - truncated: false, - })); - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - staleTimeMs: 15_000, - getClient: () => ({ searchEntries }), - }); - - manager.search(TARGET); - await flushAsyncWork(); - manager.search(TARGET); - await flushAsyncWork(); - - assert.strictEqual(searchEntries.mock.calls.length, 1); - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/index.ts", kind: "file" }], - isPending: false, - error: null, - }); -}); - -it("invalidates watched path searches and refreshes without clearing entries", async () => { - type SearchResult = Awaited>; - - let resolveSecond: (value: SearchResult) => void = noop; - let callCount = 0; - const searchEntries = vi.fn((() => { - callCount += 1; - if (callCount === 1) { - return Promise.resolve({ - entries: [{ path: "src/old.ts", kind: "file" as const }], - truncated: false, - }); - } - return new Promise((resolve) => { - resolveSecond = resolve; - }); - }) satisfies ComposerPathSearchClient["searchEntries"]); - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - staleTimeMs: 15_000, - getClient: () => ({ searchEntries }), - }); - - const unwatch = manager.watch(TARGET); - await flushAsyncWork(); - manager.invalidate(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/old.ts", kind: "file" }], - isPending: true, - error: null, - }); - - resolveSecond({ - entries: [{ path: "src/new.ts", kind: "file" }], - truncated: false, - }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot(TARGET), { - entries: [{ path: "src/new.ts", kind: "file" }], - isPending: false, - error: null, - }); - assert.strictEqual(searchEntries.mock.calls.length, 2); - unwatch(); -}); - -it("ignores stale path search results after a newer request starts", async () => { - let resolveFirst: (value: { - entries: ReadonlyArray<{ path: string; kind: "file" | "directory" }>; - truncated: boolean; - }) => void = noop; - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - debounceMs: 0, - getClient: () => ({ - searchEntries: (input: Parameters[0]) => { - if (input.query === "first") { - return new Promise((resolve) => { - resolveFirst = resolve; - }); - } - return Promise.resolve({ - entries: [{ path: "second.ts", kind: "file" }], - truncated: false, - }); - }, - }), - }); - - manager.search({ ...TARGET, query: "first" }); - manager.search({ ...TARGET, query: "second" }); - await flushAsyncWork(); - resolveFirst({ entries: [{ path: "first.ts", kind: "file" }], truncated: false }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot({ ...TARGET, query: "second" }), { - entries: [{ path: "second.ts", kind: "file" }], - isPending: false, - error: null, - }); -}); - -it("returns the empty snapshot for inactive targets", () => { - const manager = createComposerPathSearchManager({ - getRegistry: () => registry, - getClient: () => null, - }); - - assert.deepStrictEqual( - manager.getSnapshot({ environmentId: null, cwd: null, query: null }), - EMPTY_COMPOSER_PATH_SEARCH_STATE, - ); -}); diff --git a/packages/client-runtime/src/composerPathSearchState.ts b/packages/client-runtime/src/composerPathSearchState.ts deleted file mode 100644 index e8652ce561e..00000000000 --- a/packages/client-runtime/src/composerPathSearchState.ts +++ /dev/null @@ -1,346 +0,0 @@ -import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface ComposerPathSearchEntry { - readonly path: string; - readonly kind: "file" | "directory"; - readonly parentPath?: string; -} - -export interface ComposerPathSearchState { - readonly entries: ReadonlyArray; - readonly isPending: boolean; - readonly error: string | null; -} - -export interface ComposerPathSearchTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query: string | null; -} - -export interface ComposerPathSearchClient { - readonly searchEntries: (input: { - readonly cwd: string; - readonly query: string; - readonly limit: number; - }) => Promise; -} - -interface WatchedEntry { - refCount: number; - target: ComposerPathSearchTarget & { - readonly environmentId: EnvironmentId; - readonly cwd: string; - }; - teardown: () => void; -} - -export const EMPTY_COMPOSER_PATH_SEARCH_STATE = Object.freeze({ - entries: [], - isPending: false, - error: null, -}); - -const PENDING_COMPOSER_PATH_SEARCH_STATE = Object.freeze({ - entries: [], - isPending: true, - error: null, -}); - -const NOOP: () => void = () => undefined; -const DEFAULT_DEBOUNCE_MS = 200; -const DEFAULT_LIMIT = 20; - -export const composerPathSearchStateAtom = Atom.family((key: string) => - Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`composer-path-search:${key}`), - ), -); - -export const EMPTY_COMPOSER_PATH_SEARCH_ATOM = Atom.make(EMPTY_COMPOSER_PATH_SEARCH_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("composer-path-search:null"), -); - -export function normalizeComposerPathSearchQuery(query: string | null): string { - return query?.trim() ?? ""; -} - -export function getComposerPathSearchTargetKey(target: ComposerPathSearchTarget): string | null { - const query = normalizeComposerPathSearchQuery(target.query); - if (target.environmentId === null || target.cwd === null || query.length === 0) { - return null; - } - - return `${target.environmentId}:${target.cwd}:${query}`; -} - -function toSearchEntries( - entries: ProjectSearchEntriesResult["entries"], -): ReadonlyArray { - return entries.map((entry) => ({ - path: entry.path, - kind: entry.kind === "directory" ? "directory" : "file", - ...(entry.parentPath !== undefined ? { parentPath: entry.parentPath } : {}), - })); -} - -export function createComposerPathSearchManager(config: { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ComposerPathSearchClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly debounceMs?: number; - readonly limit?: number; - readonly staleTimeMs?: number; -}) { - const watched = new Map(); - const versions = new Map(); - const timers = new Map>(); - const lastLoadedAt = new Map(); - const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS; - const limit = config.limit ?? DEFAULT_LIMIT; - - function bumpVersion(targetKey: string): number { - const next = (versions.get(targetKey) ?? 0) + 1; - versions.set(targetKey, next); - return next; - } - - function setState(targetKey: string, state: ComposerPathSearchState): void { - config.getRegistry().set(composerPathSearchStateAtom(targetKey), state); - } - - function clearTimer(targetKey: string): void { - const fiber = timers.get(targetKey); - if (fiber) { - Effect.runFork(Fiber.interrupt(fiber)); - timers.delete(targetKey); - } - } - - function getSnapshot(target: ComposerPathSearchTarget): ComposerPathSearchState { - const targetKey = getComposerPathSearchTargetKey(target); - return targetKey === null - ? EMPTY_COMPOSER_PATH_SEARCH_STATE - : config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - } - - function runSearch( - targetKey: string, - target: ComposerPathSearchTarget & { - readonly environmentId: EnvironmentId; - readonly cwd: string; - }, - client: ComposerPathSearchClient, - version: number, - ): void { - void client - .searchEntries({ - cwd: target.cwd, - query: normalizeComposerPathSearchQuery(target.query), - limit, - }) - .then((result) => { - if (versions.get(targetKey) !== version) { - return; - } - setState(targetKey, { - entries: toSearchEntries(result.entries), - isPending: false, - error: null, - }); - lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); - }) - .catch((error: unknown) => { - if (versions.get(targetKey) !== version) { - return; - } - const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - setState(targetKey, { - entries: current.entries, - isPending: false, - error: error instanceof Error ? error.message : "Failed to search project files.", - }); - }); - } - - function search( - target: ComposerPathSearchTarget, - client?: ComposerPathSearchClient, - options?: { readonly force?: boolean }, - ): void { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return; - } - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - setState(targetKey, { - entries: [], - isPending: false, - error: "Remote connection is not ready.", - }); - return; - } - - const lastLoaded = lastLoadedAt.get(targetKey); - if ( - !options?.force && - lastLoaded !== undefined && - config.staleTimeMs !== undefined && - Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs - ) { - return; - } - - const version = bumpVersion(targetKey); - clearTimer(targetKey); - const current = config.getRegistry().get(composerPathSearchStateAtom(targetKey)); - setState( - targetKey, - current.entries.length === 0 - ? PENDING_COMPOSER_PATH_SEARCH_STATE - : { ...current, isPending: true, error: null }, - ); - - const readyTarget = { - ...target, - environmentId: target.environmentId, - cwd: target.cwd, - }; - - if (debounceMs <= 0) { - runSearch(targetKey, readyTarget, resolved, version); - return; - } - - const fiber = Effect.runFork( - Effect.sleep(Duration.millis(debounceMs)).pipe( - Effect.andThen( - Effect.sync(() => { - timers.delete(targetKey); - runSearch(targetKey, readyTarget, resolved, version); - }), - ), - ), - ); - timers.set(targetKey, fiber); - } - - function watch(target: ComposerPathSearchTarget): () => void { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const readyTarget = { - ...target, - environmentId: target.environmentId, - cwd: target.cwd, - }; - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let currentClient: ComposerPathSearchClient | null = null; - const sync = () => { - const client = config.getClient(target.environmentId!); - if (!client) { - currentClient = null; - setState(targetKey, { - entries: [], - isPending: false, - error: "Remote connection is not ready.", - }); - return; - } - - if (currentClient === client) { - return; - } - - currentClient = client; - search(readyTarget, client); - }; - - const unsubscribe = config.subscribeClientChanges?.(sync) ?? NOOP; - sync(); - - watched.set(targetKey, { - refCount: 1, - target: readyTarget, - teardown: () => { - unsubscribe(); - clearTimer(targetKey); - bumpVersion(targetKey); - }, - }); - - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - versions.clear(); - for (const targetKey of timers.keys()) { - clearTimer(targetKey); - } - lastLoadedAt.clear(); - } - - function invalidate(target?: ComposerPathSearchTarget): void { - if (target) { - const targetKey = getComposerPathSearchTargetKey(target); - if (targetKey === null) { - return; - } - lastLoadedAt.delete(targetKey); - const watchedEntry = watched.get(targetKey); - if (watchedEntry) { - search(watchedEntry.target, undefined, { force: true }); - } - return; - } - - lastLoadedAt.clear(); - for (const watchedEntry of watched.values()) { - search(watchedEntry.target, undefined, { force: true }); - } - } - - return { - invalidate, - getSnapshot, - search, - watch, - reset, - }; -} diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts new file mode 100644 index 00000000000..2a94ab70454 --- /dev/null +++ b/packages/client-runtime/src/connection/catalog.ts @@ -0,0 +1,143 @@ +import { DesktopSshEnvironmentTargetSchema, EnvironmentId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionAttemptError } from "./model.ts"; +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, +} from "./model.ts"; + +const ConnectionProfileBase = { + connectionId: Schema.String, + environmentId: EnvironmentId, + label: Schema.String, +}; + +export class BearerConnectionProfile extends Schema.TaggedClass()( + "BearerConnectionProfile", + { + ...ConnectionProfileBase, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + }, +) {} + +export class SshConnectionProfile extends Schema.TaggedClass()( + "SshConnectionProfile", + { + ...ConnectionProfileBase, + target: DesktopSshEnvironmentTargetSchema, + }, +) {} + +export const ConnectionProfile = Schema.Union([BearerConnectionProfile, SshConnectionProfile]); +export type ConnectionProfile = typeof ConnectionProfile.Type; + +export interface ConnectionCatalogEntry { + readonly target: ConnectionTarget; + readonly profile: Option.Option; +} + +export class BearerConnectionCredential extends Schema.TaggedClass()( + "BearerConnectionCredential", + { + token: Schema.String, + }, +) {} + +export const ConnectionCredential = Schema.Union([BearerConnectionCredential]); +export type ConnectionCredential = typeof ConnectionCredential.Type; + +export class PrimaryConnectionRegistration extends Schema.TaggedClass()( + "PrimaryConnectionRegistration", + { + target: PrimaryConnectionTarget, + }, +) {} + +export class RelayConnectionRegistration extends Schema.TaggedClass()( + "RelayConnectionRegistration", + { + target: RelayConnectionTarget, + }, +) {} + +export class BearerConnectionRegistration extends Schema.TaggedClass()( + "BearerConnectionRegistration", + { + target: BearerConnectionTarget, + profile: BearerConnectionProfile, + credential: BearerConnectionCredential, + }, +) {} + +export class SshConnectionRegistration extends Schema.TaggedClass()( + "SshConnectionRegistration", + { + target: SshConnectionTarget, + profile: SshConnectionProfile, + }, +) {} + +export const ConnectionRegistration = Schema.Union([ + RelayConnectionRegistration, + BearerConnectionRegistration, + SshConnectionRegistration, +]); +export type ConnectionRegistration = typeof ConnectionRegistration.Type; + +export function connectionRegistrationTarget( + registration: ConnectionRegistration | PrimaryConnectionRegistration, +): ConnectionTarget { + return registration.target; +} + +export function connectionRegistrationCatalogEntry( + registration: ConnectionRegistration | PrimaryConnectionRegistration, +): ConnectionCatalogEntry { + switch (registration._tag) { + case "PrimaryConnectionRegistration": + case "RelayConnectionRegistration": + return { + target: registration.target, + profile: Option.none(), + }; + case "BearerConnectionRegistration": + case "SshConnectionRegistration": + return { + target: registration.target, + profile: Option.some(registration.profile), + }; + } +} + +export class ConnectionProfileStore extends Context.Service< + ConnectionProfileStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: (profile: ConnectionProfile) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/catalog/ConnectionProfileStore") {} + +export class ConnectionCredentialStore extends Context.Service< + ConnectionCredentialStore, + { + readonly get: ( + connectionId: string, + ) => Effect.Effect, ConnectionAttemptError>; + readonly put: ( + connectionId: string, + credential: ConnectionCredential, + ) => Effect.Effect; + readonly remove: (connectionId: string) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/catalog/ConnectionCredentialStore") {} diff --git a/packages/client-runtime/src/connection/connectivity.ts b/packages/client-runtime/src/connection/connectivity.ts new file mode 100644 index 00000000000..44b38a3082e --- /dev/null +++ b/packages/client-runtime/src/connection/connectivity.ts @@ -0,0 +1,13 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Stream from "effect/Stream"; + +import type { NetworkStatus } from "./model.ts"; + +export class Connectivity extends Context.Service< + Connectivity, + { + readonly status: Effect.Effect; + readonly changes: Stream.Stream; + } +>()("@t3tools/client-runtime/connection/connectivity") {} diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts new file mode 100644 index 00000000000..c1a8f67a759 --- /dev/null +++ b/packages/client-runtime/src/connection/driver.ts @@ -0,0 +1,66 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as Scope from "effect/Scope"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import type { + ConnectionAttemptError, + ConnectionAttemptStage, + PreparedConnection, +} from "./model.ts"; +import { ConnectionResolver } from "./resolver.ts"; +import { RpcSessionFactory, type RpcSession } from "../rpc/session.ts"; + +export type ConnectionDriverProgress = + | { + readonly stage: "preparing"; + } + | { + readonly stage: Exclude; + readonly prepared: PreparedConnection; + }; + +export interface EnvironmentConnectionLease { + readonly prepared: PreparedConnection; + readonly session: RpcSession; +} + +export interface ConnectionDriverService { + readonly connect: ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) => Effect.Effect; +} + +export class ConnectionDriver extends Context.Service()( + "@t3tools/client-runtime/connection/driver/ConnectionDriver", +) {} + +export const connectionDriverLayer = Layer.effect( + ConnectionDriver, + Effect.gen(function* () { + const resolver = yield* ConnectionResolver; + const sessions = yield* RpcSessionFactory; + + const connect = Effect.fn("ConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, + }); + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* resolver.prepare(entry); + yield* reportProgress({ stage: "opening", prepared }); + const session = yield* sessions.connect(prepared); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies EnvironmentConnectionLease; + }); + + return ConnectionDriver.of({ connect }); + }), +); diff --git a/packages/client-runtime/src/connection/errors.ts b/packages/client-runtime/src/connection/errors.ts new file mode 100644 index 00000000000..ab5baec3364 --- /dev/null +++ b/packages/client-runtime/src/connection/errors.ts @@ -0,0 +1,140 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayProtectedError } from "@t3tools/contracts/relay"; +import type { ManagedRelayClientError } from "../relay/managedRelay.ts"; +import type { RemoteEnvironmentAuthError } from "../authorization/remote.ts"; +import { + ConnectionBlockedError, + type ConnectionAttemptError, + ConnectionTransientError, +} from "./model.ts"; + +export function profileMissingError(connectionId: string): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "configuration", + message: `Connection profile ${connectionId} is unavailable.`, + }); +} + +export function credentialMissingError(connectionId: string): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "authentication", + message: `Connection credential ${connectionId} is unavailable.`, + }); +} + +export function environmentMismatchError(input: { + readonly expected: EnvironmentId; + readonly actual: EnvironmentId; +}): ConnectionBlockedError { + return new ConnectionBlockedError({ + reason: "configuration", + message: `Connected environment ${input.actual} does not match ${input.expected}.`, + }); +} + +function relayProtectedError(error: RelayProtectedError): ConnectionAttemptError { + switch (error._tag) { + case "RelayAuthInvalidError": + case "RelayEnvironmentLinkProofExpiredError": + case "RelayAgentActivityPublishProofExpiredError": + case "RelayAgentActivityPublishProofInvalidError": + return new ConnectionBlockedError({ + reason: "authentication", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentConnectNotAuthorizedError": + case "RelayEnvironmentLinkProofInvalidError": + return new ConnectionBlockedError({ + reason: "permission", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentEndpointTimedOutError": + return new ConnectionTransientError({ + reason: "timeout", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentEndpointUnavailableError": + case "RelayEnvironmentLinkUnavailableError": + return new ConnectionTransientError({ + reason: "endpoint-unavailable", + message: error.message, + traceId: error.traceId, + }); + case "RelayEnvironmentLinkFailedError": + case "RelayInternalError": + return new ConnectionTransientError({ + reason: "relay-unavailable", + message: error.message, + traceId: error.traceId, + }); + } +} + +export function mapManagedRelayError(error: ManagedRelayClientError): ConnectionAttemptError { + if (error.relayError) { + return relayProtectedError(error.relayError); + } + if (error.cause?._tag === "ManagedRelayRequestTimeoutError") { + return new ConnectionTransientError({ + reason: "timeout", + message: error.message, + ...(error.traceId ? { traceId: error.traceId } : {}), + }); + } + return new ConnectionTransientError({ + reason: "relay-unavailable", + message: error.message, + ...(error.traceId ? { traceId: error.traceId } : {}), + }); +} + +export function mapRemoteEnvironmentError( + error: RemoteEnvironmentAuthError, +): ConnectionAttemptError { + switch (error._tag) { + case "EnvironmentAuthInvalidError": + return new ConnectionBlockedError({ + reason: "authentication", + message: "The environment credential is invalid.", + traceId: error.traceId, + }); + case "EnvironmentScopeRequiredError": + case "EnvironmentOperationForbiddenError": + return new ConnectionBlockedError({ + reason: "permission", + message: "The environment credential does not grant the required access.", + traceId: error.traceId, + }); + case "EnvironmentRequestInvalidError": + return new ConnectionBlockedError({ + reason: "configuration", + message: "The environment rejected the authentication request.", + traceId: error.traceId, + }); + case "RemoteEnvironmentAuthTimeoutError": + return new ConnectionTransientError({ + reason: "timeout", + message: error.message, + }); + case "RemoteEnvironmentAuthFetchError": + return new ConnectionTransientError({ + reason: "network", + message: error.message, + }); + case "EnvironmentInternalError": + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: "The environment could not authorize the connection.", + traceId: error.traceId, + }); + case "RemoteEnvironmentAuthInvalidJsonError": + case "RemoteEnvironmentAuthUndeclaredStatusError": + return new ConnectionTransientError({ + reason: "remote-unavailable", + message: error.message, + }); + } +} diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts new file mode 100644 index 00000000000..eb1db447bff --- /dev/null +++ b/packages/client-runtime/src/connection/index.ts @@ -0,0 +1,12 @@ +export * from "./catalog.ts"; +export * from "./connectivity.ts"; +export * from "./driver.ts"; +export * from "./errors.ts"; +export * from "./layer.ts"; +export * from "./model.ts"; +export * from "./onboarding.ts"; +export * from "./presentation.ts"; +export * from "./registry.ts"; +export * from "./resolver.ts"; +export * from "./supervisor.ts"; +export * from "./wakeups.ts"; diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts new file mode 100644 index 00000000000..c485c6c1b2c --- /dev/null +++ b/packages/client-runtime/src/connection/layer.ts @@ -0,0 +1,46 @@ +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { connectionResolverLayer } from "./resolver.ts"; +import { connectionDriverLayer } from "./driver.ts"; +import { environmentRegistryLayer, EnvironmentRegistry } from "./registry.ts"; +import { connectionOnboardingLayer } from "./onboarding.ts"; +import { PlatformConnectionSource } from "../platform/source.ts"; +import { relayEnvironmentDiscoveryLayer } from "../relay/discovery.ts"; +import { remoteEnvironmentAuthorizationLayer } from "../authorization/layer.ts"; +import { rpcSessionFactoryLayer } from "../rpc/session.ts"; + +const resolverLayer = connectionResolverLayer.pipe( + Layer.provide(remoteEnvironmentAuthorizationLayer), +); + +const driverLayer = connectionDriverLayer.pipe( + Layer.provide(Layer.mergeAll(resolverLayer, rpcSessionFactoryLayer)), +); + +const registryLayer = environmentRegistryLayer.pipe(Layer.provide(driverLayer)); + +const onboardingLayer = connectionOnboardingLayer.pipe(Layer.provide(registryLayer)); + +const connectionServicesLayer = Layer.mergeAll( + registryLayer, + relayEnvironmentDiscoveryLayer, + onboardingLayer, +); + +const connectionStartupLayer = Layer.effectDiscard( + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const platformSource = yield* PlatformConnectionSource; + yield* registry.start; + yield* platformSource.registrations.pipe( + Stream.runForEach(registry.registerPlatform), + Effect.forkScoped, + ); + }).pipe(Effect.withSpan("clientRuntime.connection.application.start")), +); + +export const connectionLayer = connectionStartupLayer.pipe( + Layer.provideMerge(connectionServicesLayer), +); diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts new file mode 100644 index 00000000000..5c1daf090e4 --- /dev/null +++ b/packages/client-runtime/src/connection/model.ts @@ -0,0 +1,168 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const ConnectionTargetBase = { + environmentId: EnvironmentId, + label: Schema.String, +}; + +export class PrimaryConnectionTarget extends Schema.TaggedClass()( + "PrimaryConnectionTarget", + { + ...ConnectionTargetBase, + httpBaseUrl: Schema.String, + wsBaseUrl: Schema.String, + }, +) {} + +export class BearerConnectionTarget extends Schema.TaggedClass()( + "BearerConnectionTarget", + { + ...ConnectionTargetBase, + connectionId: Schema.String, + }, +) {} + +export class RelayConnectionTarget extends Schema.TaggedClass()( + "RelayConnectionTarget", + { + ...ConnectionTargetBase, + }, +) {} + +export class SshConnectionTarget extends Schema.TaggedClass()( + "SshConnectionTarget", + { + ...ConnectionTargetBase, + connectionId: Schema.String, + }, +) {} + +export const ConnectionTarget = Schema.Union([ + PrimaryConnectionTarget, + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +]); +export type ConnectionTarget = typeof ConnectionTarget.Type; + +export const PersistedConnectionTarget = Schema.Union([ + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +]); +export type PersistedConnectionTarget = typeof PersistedConnectionTarget.Type; + +export type ConnectionTargetKind = ConnectionTarget["_tag"]; + +export type NetworkStatus = "unknown" | "offline" | "online"; + +export type ConnectionTransientReason = + | "network" + | "timeout" + | "transport" + | "endpoint-unavailable" + | "relay-unavailable" + | "remote-unavailable"; + +export type ConnectionBlockedReason = + | "authentication" + | "configuration" + | "permission" + | "unsupported"; + +export class ConnectionTransientError extends Schema.TaggedErrorClass()( + "ConnectionTransientError", + { + reason: Schema.Literals([ + "network", + "timeout", + "transport", + "endpoint-unavailable", + "relay-unavailable", + "remote-unavailable", + ]), + message: Schema.String, + traceId: Schema.optionalKey(Schema.String), + }, +) {} + +export class ConnectionBlockedError extends Schema.TaggedErrorClass()( + "ConnectionBlockedError", + { + reason: Schema.Literals(["authentication", "configuration", "permission", "unsupported"]), + message: Schema.String, + traceId: Schema.optionalKey(Schema.String), + }, +) {} + +export type ConnectionAttemptError = ConnectionTransientError | ConnectionBlockedError; + +export type PreparedHttpAuthorization = + | { + readonly _tag: "Bearer"; + readonly token: string; + } + | { + readonly _tag: "Dpop"; + readonly accessToken: string; + }; + +export interface PreparedConnection { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; + readonly socketUrl: string; + readonly httpAuthorization: PreparedHttpAuthorization | null; + readonly target: ConnectionTarget; +} + +export type SupervisorConnectionPhase = + | "available" + | "offline" + | "connecting" + | "backoff" + | "connected" + | "blocked"; + +export type ConnectionAttemptStage = "preparing" | "opening" | "synchronizing"; + +export interface SupervisorConnectionState { + readonly desired: boolean; + readonly network: NetworkStatus; + readonly phase: SupervisorConnectionPhase; + readonly stage: ConnectionAttemptStage | null; + readonly attempt: number; + readonly generation: number; + readonly lastFailure: ConnectionAttemptError | null; + readonly retryAt: number | null; +} + +export type ConnectionProjectionPhase = "disconnected" | "synchronizing" | "ready"; + +export function connectionProjectionPhase( + state: SupervisorConnectionState, +): ConnectionProjectionPhase { + switch (state.phase) { + case "connecting": + return "synchronizing"; + case "connected": + return "ready"; + case "available": + case "offline": + case "backoff": + case "blocked": + return "disconnected"; + } +} + +export const AVAILABLE_CONNECTION_STATE: SupervisorConnectionState = Object.freeze({ + desired: false, + network: "unknown", + phase: "available", + stage: null, + attempt: 0, + generation: 0, + lastFailure: null, + retryAt: null, +}); diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts new file mode 100644 index 00000000000..1af021359fe --- /dev/null +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -0,0 +1,226 @@ +import { AuthStandardClientScopes, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts"; +import { BearerConnectionTarget } from "./model.ts"; +import { + prepareBearerConnectionUpdate, + preparePairingRegistration, + prepareSshRegistration, +} from "./onboarding.ts"; + +const CLIENT_PRESENTATION_LAYER = Layer.succeed( + ClientPresentation, + ClientPresentation.of({ + metadata: { + label: "T3 Code Test", + deviceType: "desktop", + os: "Test OS", + }, + scopes: AuthStandardClientScopes, + }), +); + +function pairingHttpLayer(calls: Array<{ readonly url: string; readonly init: RequestInit }>) { + const fetchFn = ((input, init = {}) => { + const url = String(input); + calls.push({ url, init }); + + if (url.endsWith("/.well-known/t3/environment")) { + return Promise.resolve( + Response.json({ + environmentId: "environment-paired", + label: "Paired environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }), + ); + } + + if (url.endsWith("/oauth/token")) { + return Promise.resolve( + Response.json({ + access_token: "bearer-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 3600, + scope: AuthStandardClientScopes.join(" "), + }), + ); + } + + return Promise.reject(new Error(`Unexpected request: ${url}`)); + }) satisfies typeof fetch; + + return remoteHttpClientLayer(fetchFn); +} + +describe("connection onboarding", () => { + it.effect("prepares a persisted bearer registration from pairing details", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const registration = yield* preparePairingRegistration({ + host: "remote.example.test", + pairingCode: "pairing-token", + }).pipe(Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls)))); + + expect(registration).toMatchObject({ + _tag: "BearerConnectionRegistration", + target: { + environmentId: "environment-paired", + label: "Paired environment", + connectionId: "bearer:environment-paired", + }, + profile: { + environmentId: "environment-paired", + label: "Paired environment", + connectionId: "bearer:environment-paired", + httpBaseUrl: "https://remote.example.test/", + wsBaseUrl: "wss://remote.example.test/", + }, + credential: { + token: "bearer-token", + }, + }); + expect(calls.map((call) => call.url).toSorted()).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + "https://remote.example.test/oauth/token", + ]); + + const tokenRequest = calls.find((call) => call.url.endsWith("/oauth/token")); + const tokenBody = + tokenRequest?.init.body instanceof Uint8Array + ? new TextDecoder().decode(tokenRequest.init.body) + : String(tokenRequest?.init.body); + const tokenParams = new URLSearchParams(tokenBody); + expect(tokenParams.get("subject_token")).toBe("pairing-token"); + expect(tokenParams.get("scope")).toBe(AuthStandardClientScopes.join(" ")); + expect(tokenParams.get("client_label")).toBe("T3 Code Test"); + }), + ); + + it.effect("rejects invalid pairing details before making a request", () => + Effect.gen(function* () { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + const error = yield* preparePairingRegistration({ + host: "", + pairingCode: "", + }).pipe( + Effect.provide(Layer.mergeAll(CLIENT_PRESENTATION_LAYER, pairingHttpLayer(calls))), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "ConnectionBlockedError", + reason: "configuration", + message: "Enter a backend URL.", + }); + expect(calls).toEqual([]); + }), + ); + + it.effect("updates bearer metadata while preserving the credential and identity", () => + Effect.gen(function* () { + const environmentId = EnvironmentId.make("environment-paired"); + const registration = yield* prepareBearerConnectionUpdate({ + input: { + environmentId, + label: " Renamed environment ", + httpBaseUrl: "http://100.65.180.100:3773/path", + }, + entry: Option.some({ + target: new BearerConnectionTarget({ + environmentId, + label: "Old label", + connectionId: "bearer:environment-paired", + }), + profile: Option.some( + new BearerConnectionProfile({ + connectionId: "bearer:environment-paired", + environmentId, + label: "Old label", + httpBaseUrl: "http://old.example.test/", + wsBaseUrl: "ws://old.example.test/", + }), + ), + }), + credential: Option.some(new BearerConnectionCredential({ token: "bearer-token" })), + }); + + expect(registration).toMatchObject({ + target: { + environmentId, + label: "Renamed environment", + connectionId: "bearer:environment-paired", + }, + profile: { + environmentId, + label: "Renamed environment", + httpBaseUrl: "http://100.65.180.100:3773/", + wsBaseUrl: "ws://100.65.180.100:3773/", + }, + credential: { token: "bearer-token" }, + }); + }), + ); + + it.effect("prepares an SSH registration from the provisioned platform environment", () => + Effect.gen(function* () { + const target = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }; + const registration = yield* prepareSshRegistration({ + target, + }).pipe( + Effect.provideService( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.succeed({ + environmentId: EnvironmentId.make("environment-ssh"), + label: "Remote development box", + bootstrap: { + target, + httpBaseUrl: "http://127.0.0.1:3201", + wsBaseUrl: "ws://127.0.0.1:3201", + pairingToken: "pairing-token", + }, + bearerToken: "bearer-token", + }), + prepare: () => Effect.die("unused"), + disconnect: () => Effect.die("unused"), + }), + ), + ); + + expect(registration).toMatchObject({ + _tag: "SshConnectionRegistration", + target: { + environmentId: "environment-ssh", + label: "Remote development box", + connectionId: "ssh:environment-ssh", + }, + profile: { + environmentId: "environment-ssh", + label: "Remote development box", + connectionId: "ssh:environment-ssh", + target, + }, + }); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts new file mode 100644 index 00000000000..c1e0131a28b --- /dev/null +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -0,0 +1,272 @@ +import type { DesktopSshEnvironmentTarget, EnvironmentId } from "@t3tools/contracts"; +import { resolveRemotePairingTarget } from "@t3tools/shared/remote"; +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 Schema from "effect/Schema"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient } from "effect/unstable/http"; + +import { bootstrapRemoteBearerSession } from "../authorization/remote.ts"; +import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "../environment/endpoint.ts"; +import { fetchRemoteEnvironmentDescriptor } from "../environment/descriptor.ts"; +import { ClientPresentation, SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + type ConnectionCatalogEntry, + type ConnectionCredential, + ConnectionCredentialStore, + SshConnectionProfile, + SshConnectionRegistration, +} from "./catalog.ts"; +import { mapRemoteEnvironmentError } from "./errors.ts"; +import { + BearerConnectionTarget, + ConnectionBlockedError, + SshConnectionTarget, + type ConnectionAttemptError, +} from "./model.ts"; +import type { ConnectionPersistenceError } from "../platform/persistence.ts"; +import { EnvironmentRegistry } from "./registry.ts"; + +export interface PairingConnectionInput { + readonly pairingUrl?: string; + readonly host?: string; + readonly pairingCode?: string; +} + +export interface SshConnectionInput { + readonly target: DesktopSshEnvironmentTarget; + readonly label?: string; +} + +export interface BearerConnectionUpdateInput { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly httpBaseUrl: string; +} + +export class ConnectionOnboarding extends Context.Service< + ConnectionOnboarding, + { + readonly registerPairing: ( + input: PairingConnectionInput, + ) => Effect.Effect; + readonly registerSsh: ( + input: SshConnectionInput, + ) => Effect.Effect; + readonly updateBearer: ( + input: BearerConnectionUpdateInput, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/onboarding/ConnectionOnboarding") {} + +const resolvePairingTarget = Effect.fn("clientRuntime.connection.onboarding.resolvePairingTarget")( + function* (input: PairingConnectionInput) { + return yield* Effect.try({ + try: () => resolveRemotePairingTarget(input), + catch: (cause) => + new ConnectionBlockedError({ + reason: "configuration", + message: cause instanceof Error ? cause.message : "The pairing details are invalid.", + }), + }); + }, +); + +export const preparePairingRegistration = Effect.fn( + "clientRuntime.connection.onboarding.preparePairingRegistration", +)(function* (input: PairingConnectionInput) { + const target = yield* resolvePairingTarget(input); + const presentation = yield* ClientPresentation; + const { descriptor, access } = yield* Effect.all( + { + descriptor: fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: target.httpBaseUrl, + }), + access: bootstrapRemoteBearerSession({ + httpBaseUrl: target.httpBaseUrl, + credential: target.credential, + scopes: presentation.scopes, + clientMetadata: presentation.metadata, + }), + }, + { concurrency: "unbounded" }, + ).pipe(Effect.mapError(mapRemoteEnvironmentError)); + const connectionId = `bearer:${descriptor.environmentId}`; + + return new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: descriptor.environmentId, + label: descriptor.label, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: descriptor.environmentId, + label: descriptor.label, + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + }), + credential: new BearerConnectionCredential({ + token: access.access_token, + }), + }); +}); + +export const registerPairingConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerPairingConnection", +)(function* (input: PairingConnectionInput) { + const registration = yield* preparePairingRegistration(input); + const registry = yield* EnvironmentRegistry; + yield* registry.register(registration); + return registration.target.environmentId; +}); + +const isBearerCredential = Schema.is(BearerConnectionCredential); +const isBearerProfile = Schema.is(BearerConnectionProfile); + +export const updateBearerConnection = Effect.fn( + "clientRuntime.connection.onboarding.updateBearerConnection", +)(function* (input: BearerConnectionUpdateInput) { + const registry = yield* EnvironmentRegistry; + const credentials = yield* ConnectionCredentialStore; + const entry = (yield* SubscriptionRef.get(registry.entries)).get(input.environmentId); + const credential = + entry?.target._tag === "BearerConnectionTarget" + ? yield* credentials.get(entry.target.connectionId) + : Option.none(); + const registration = yield* prepareBearerConnectionUpdate({ + input, + entry: Option.fromUndefinedOr(entry), + credential, + }); + yield* registry.register(registration); +}); + +export const prepareBearerConnectionUpdate = Effect.fn( + "clientRuntime.connection.onboarding.prepareBearerConnectionUpdate", +)(function* (options: { + readonly input: BearerConnectionUpdateInput; + readonly entry: Option.Option; + readonly credential: Option.Option; +}) { + const entry = Option.getOrNull(options.entry); + if ( + entry === undefined || + entry === null || + entry.target._tag !== "BearerConnectionTarget" || + Option.isNone(entry.profile) || + !isBearerProfile(entry.profile.value) + ) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: "Only saved bearer environments can be edited.", + }); + } + + const credential = options.credential; + if (Option.isNone(credential) || !isBearerCredential(credential.value)) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + message: "The saved bearer credential is unavailable.", + }); + } + + const label = options.input.label.trim(); + if (label === "") { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: "Environment label cannot be empty.", + }); + } + const httpBaseUrl = yield* Effect.try({ + try: () => normalizeHttpBaseUrl(options.input.httpBaseUrl), + catch: (cause) => + new ConnectionBlockedError({ + reason: "configuration", + message: cause instanceof Error ? cause.message : "The environment URL is invalid.", + }), + }); + const connectionId = entry.target.connectionId; + return new BearerConnectionRegistration({ + target: new BearerConnectionTarget({ + environmentId: options.input.environmentId, + label, + connectionId, + }), + profile: new BearerConnectionProfile({ + connectionId, + environmentId: options.input.environmentId, + label, + httpBaseUrl, + wsBaseUrl: deriveWsBaseUrl(httpBaseUrl), + }), + credential: credential.value, + }); +}); + +export const prepareSshRegistration = Effect.fn( + "clientRuntime.connection.onboarding.prepareSshRegistration", +)(function* (input: SshConnectionInput) { + const gateway = yield* SshEnvironmentGateway; + const provisioned = yield* gateway.provision(input.target); + const connectionId = `ssh:${provisioned.environmentId}`; + const label = input.label?.trim() || provisioned.label || provisioned.bootstrap.target.alias; + + return new SshConnectionRegistration({ + target: new SshConnectionTarget({ + environmentId: provisioned.environmentId, + label, + connectionId, + }), + profile: new SshConnectionProfile({ + connectionId, + environmentId: provisioned.environmentId, + label, + target: provisioned.bootstrap.target, + }), + }); +}); + +export const registerSshConnection = Effect.fn( + "clientRuntime.connection.onboarding.registerSshConnection", +)(function* (input: SshConnectionInput) { + const registration = yield* prepareSshRegistration(input); + const registry = yield* EnvironmentRegistry; + yield* registry.register(registration); + return registration.target.environmentId; +}); + +export const connectionOnboardingLayer = Layer.effect( + ConnectionOnboarding, + Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const presentation = yield* ClientPresentation; + const httpClient = yield* HttpClient.HttpClient; + const ssh = yield* SshEnvironmentGateway; + const credentials = yield* ConnectionCredentialStore; + + return ConnectionOnboarding.of({ + registerPairing: (input) => + registerPairingConnection(input).pipe( + Effect.provideService(EnvironmentRegistry, registry), + Effect.provideService(ClientPresentation, presentation), + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + registerSsh: (input) => + registerSshConnection(input).pipe( + Effect.provideService(EnvironmentRegistry, registry), + Effect.provideService(SshEnvironmentGateway, ssh), + ), + updateBearer: (input) => + updateBearerConnection(input).pipe( + Effect.provideService(EnvironmentRegistry, registry), + Effect.provideService(ConnectionCredentialStore, credentials), + ), + }); + }), +); diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts new file mode 100644 index 00000000000..d28dd65c18a --- /dev/null +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -0,0 +1,184 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionTarget, + ConnectionTransientError, + type SupervisorConnectionState, +} from "./model.ts"; +import { + connectionCatalogDisplayUrl, + connectionPhaseMessage, + connectionStatusText, + presentEnvironmentConnection, + presentConnectionState, +} from "./presentation.ts"; + +const TARGET = new BearerConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + connectionId: "connection-1", +}); + +const ENTRY: ConnectionCatalogEntry = { + target: TARGET, + profile: Option.some( + new BearerConnectionProfile({ + connectionId: TARGET.connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + }), + ), +}; + +function supervisorState(overrides: Partial): SupervisorConnectionState { + return { + desired: true, + network: "online", + phase: "connecting", + stage: "preparing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + ...overrides, + }; +} + +describe("connection presentation", () => { + it("preserves profile display information without exposing credentials", () => { + expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); + }); + + it("distinguishes initial connection, reconnect, and retry errors", () => { + expect(presentConnectionState(supervisorState({ phase: "connecting", attempt: 1 }))).toEqual({ + phase: "connecting", + error: null, + traceId: null, + }); + expect( + presentConnectionState( + supervisorState({ + phase: "connecting", + attempt: 2, + lastFailure: new ConnectionTransientError({ + reason: "transport", + message: "Socket closed.", + traceId: "trace-previous", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Socket closed.", + traceId: "trace-previous", + }); + expect( + presentConnectionState( + supervisorState({ + phase: "backoff", + attempt: 2, + retryAt: 1, + lastFailure: new ConnectionTransientError({ + reason: "transport", + message: "Disconnected.", + traceId: "trace-1", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Disconnected.", + traceId: "trace-1", + }); + }); + + it("preserves the latest failure while the next attempt is active", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + phase: "connecting", + stage: "opening", + attempt: 2, + lastFailure: new ConnectionTransientError({ + reason: "transport", + message: "Relay connection timed out.", + traceId: "trace-retry", + }), + }), + ), + ).toEqual({ + phase: "reconnecting", + error: "Relay connection timed out.", + traceId: "trace-retry", + }); + }); + + it("gives offline status precedence in global messaging", () => { + expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); + }); + + it("combines reconnect progress with the latest failure", () => { + expect( + connectionStatusText({ + phase: "reconnecting", + error: "Relay request timed out.", + traceId: "trace-retry", + }), + ).toBe("Failed to connect. Reconnecting... Reason: Relay request timed out."); + }); + + it("presents the supervisor's offline state without consulting shell state", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + network: "offline", + phase: "offline", + stage: null, + }), + ), + ).toEqual({ + phase: "offline", + error: null, + traceId: null, + }); + }); + + it("presents a connected supervisor snapshot as connected", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + phase: "connected", + stage: null, + generation: 1, + }), + ), + ).toEqual({ + phase: "connected", + error: null, + traceId: null, + }); + }); + + it("preserves an explicitly available environment while offline", () => { + expect( + presentEnvironmentConnection( + supervisorState({ + desired: false, + network: "offline", + phase: "available", + stage: null, + attempt: 0, + }), + ), + ).toEqual({ + phase: "available", + error: null, + traceId: null, + }); + }); +}); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts new file mode 100644 index 00000000000..ec7687dfe42 --- /dev/null +++ b/packages/client-runtime/src/connection/presentation.ts @@ -0,0 +1,122 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; + +export type EnvironmentConnectionPhase = + | "available" + | "offline" + | "connecting" + | "reconnecting" + | "connected" + | "error"; + +export interface EnvironmentConnectionPresentation { + readonly phase: EnvironmentConnectionPhase; + readonly error: string | null; + readonly traceId: string | null; +} + +export interface EnvironmentPresentation { + readonly entry: ConnectionCatalogEntry; + readonly connection: EnvironmentConnectionPresentation; + readonly serverConfig: ServerConfig | null; +} + +export function presentConnectionState( + state: SupervisorConnectionState, +): EnvironmentConnectionPresentation { + switch (state.phase) { + case "available": + return { phase: "available", error: null, traceId: null }; + case "offline": + return { phase: "offline", error: null, traceId: null }; + case "connecting": + return { + phase: state.attempt <= 1 && state.lastFailure === null ? "connecting" : "reconnecting", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + case "connected": + return { phase: "connected", error: null, traceId: null }; + case "backoff": + return { + phase: "reconnecting", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + case "blocked": + return { + phase: "error", + error: state.lastFailure?.message ?? null, + traceId: state.lastFailure?.traceId ?? null, + }; + } +} + +export function connectionStatusText(connection: EnvironmentConnectionPresentation): string { + switch (connection.phase) { + case "available": + return "Available"; + case "offline": + return "Offline"; + case "connecting": + return "Connecting..."; + case "reconnecting": + return connection.error + ? `Failed to connect. Reconnecting... Reason: ${connection.error}` + : "Reconnecting..."; + case "connected": + return "Connected"; + case "error": + return connection.error + ? `Connection failed. Reason: ${connection.error}` + : "Connection failed"; + } +} + +export function presentEnvironmentConnection( + state: SupervisorConnectionState, +): EnvironmentConnectionPresentation { + return presentConnectionState(state); +} + +export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): string | null { + switch (entry.target._tag) { + case "PrimaryConnectionTarget": + return entry.target.httpBaseUrl; + case "RelayConnectionTarget": + return null; + case "BearerConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "BearerConnectionProfile" + ? entry.profile.value.httpBaseUrl + : null; + case "SshConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile" + ? `${entry.profile.value.target.username}@${entry.profile.value.target.hostname}` + : null; + } +} + +export function connectionPhaseMessage( + phase: EnvironmentConnectionPhase, + label: string, + networkStatus: NetworkStatus, +): string { + if (networkStatus === "offline" || phase === "offline") { + return "You are offline"; + } + switch (phase) { + case "available": + return "Available"; + case "connecting": + return `Connecting to ${label}...`; + case "reconnecting": + return `Reconnecting to ${label}...`; + case "connected": + return "Connected"; + case "error": + return "Connection failed"; + } +} diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts new file mode 100644 index 00000000000..10a5482a0c0 --- /dev/null +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -0,0 +1,829 @@ +import { + type DesktopSshEnvironmentTarget, + EnvironmentId, + type OrchestrationShellSnapshot, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { RemoteDpopAccessToken, RemoteDpopAccessTokenStore } from "../authorization/tokenStore.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + ConnectionCredentialStore, + ConnectionProfileStore, + PrimaryConnectionRegistration, + RelayConnectionRegistration, + SshConnectionProfile, + type ConnectionCredential, + type ConnectionProfile, +} from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import { ConnectionDriver } from "./driver.ts"; +import { + ConnectionTransientError, + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import { + ConnectionRegistrationStore, + ConnectionTargetStore, + EnvironmentCacheStore, + EnvironmentOwnedDataCleanup, +} from "../platform/persistence.ts"; +import { EnvironmentRegistry, environmentRegistryLayer } from "./registry.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { EnvironmentSupervisor } from "./supervisor.ts"; +import { ConnectionWakeups } from "./wakeups.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const SECOND_TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-2"), + label: "Second environment", + httpBaseUrl: "https://environment-2.example.test", + wsBaseUrl: "wss://environment-2.example.test", +}); + +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: null, + target: TARGET, +}; + +const RELAY_TARGET = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-relay"), + label: "Relay environment", +}); +const SECOND_RELAY_TARGET = new RelayConnectionTarget({ + environmentId: EnvironmentId.make("environment-relay-2"), + label: "Second relay environment", +}); + +const BEARER_TARGET = new BearerConnectionTarget({ + environmentId: EnvironmentId.make("environment-bearer"), + label: "Bearer environment", + connectionId: "bearer-connection", +}); +const BEARER_PROFILE = new BearerConnectionProfile({ + connectionId: BEARER_TARGET.connectionId, + environmentId: BEARER_TARGET.environmentId, + label: BEARER_TARGET.label, + httpBaseUrl: "https://bearer.example.test", + wsBaseUrl: "wss://bearer.example.test", +}); +const BEARER_CREDENTIAL = new BearerConnectionCredential({ + token: "bearer-token", +}); + +const SSH_TARGET: DesktopSshEnvironmentTarget = { + alias: "test", + hostname: "test.example.test", + username: "developer", + port: 22, +}; +const SSH_CONNECTION = new SshConnectionTarget({ + environmentId: EnvironmentId.make("environment-ssh"), + label: "SSH environment", + connectionId: "ssh-connection", +}); +const SSH_PROFILE = new SshConnectionProfile({ + connectionId: SSH_CONNECTION.connectionId, + environmentId: SSH_CONNECTION.environmentId, + label: SSH_CONNECTION.label, + target: SSH_TARGET, +}); + +const CACHED_SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", +}; + +interface SessionControl { + readonly closed: Deferred.Deferred; +} + +const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( + initialTargets: ReadonlyArray, + initialProfiles: ReadonlyArray = [], + initialCredentials: ReadonlyArray = [], + options?: { + readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect; + readonly beforeRegistrationRemove?: (target: ConnectionTarget) => Effect.Effect; + }, +) { + const storedTargets = yield* Ref.make( + new Map(initialTargets.map((target) => [target.environmentId, target])), + ); + const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]])); + const cacheClears = yield* Ref.make>([]); + const ownedDataClears = yield* Ref.make>([]); + const sessions = yield* Ref.make>([]); + const storedProfiles = yield* Ref.make( + new Map(initialProfiles.map((profile) => [profile.connectionId, profile])), + ); + const profileReadCount = yield* Ref.make(0); + const storedCredentials = yield* Ref.make(new Map(initialCredentials)); + const storedRemoteTokens = yield* Ref.make( + new Map([ + [ + SSH_CONNECTION.environmentId, + new RemoteDpopAccessToken({ + environmentId: SSH_CONNECTION.environmentId, + label: SSH_CONNECTION.label, + endpoint: { + httpBaseUrl: "https://ssh.example.test", + wsBaseUrl: "wss://ssh.example.test", + providerKind: "cloudflare_tunnel", + }, + accessToken: "cached-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + dpopThumbprint: "thumbprint", + }), + ], + ]), + ); + const disconnectedSshTargets = yield* Ref.make>([]); + + const targetStore = ConnectionTargetStore.of({ + list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), + }); + const registrationStore = ConnectionRegistrationStore.of({ + register: (registration) => + Effect.gen(function* () { + yield* Ref.update(storedTargets, (current) => { + const next = new Map(current); + next.set(registration.target.environmentId, registration.target); + return next; + }); + switch (registration._tag) { + case "RelayConnectionRegistration": + return; + case "BearerConnectionRegistration": + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(registration.profile.connectionId, registration.profile); + return next; + }); + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.set(registration.target.connectionId, registration.credential); + return next; + }); + return; + case "SshConnectionRegistration": + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(registration.profile.connectionId, registration.profile); + return next; + }); + } + }), + remove: (target) => + Effect.gen(function* () { + yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void; + yield* Ref.update(storedTargets, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); + if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") { + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.delete(target.connectionId); + return next; + }); + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.delete(target.connectionId); + return next; + }); + } + yield* Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); + }), + }); + const cacheStore = EnvironmentCacheStore.of({ + loadShell: (environmentId) => + Ref.get(shellCache).pipe( + Effect.map((cache) => Option.fromUndefinedOr(cache.get(environmentId))), + ), + saveShell: (environmentId, snapshot) => + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.set(environmentId, snapshot); + return next; + }), + loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), + saveThread: (_environmentId, _thread) => Effect.void, + removeThread: (_environmentId, _threadId) => Effect.void, + clear: (environmentId) => + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }).pipe( + Effect.andThen( + Ref.update(cacheClears, (environmentIds) => [...environmentIds, environmentId]), + ), + ), + }); + const ownedDataCleanup = EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), + }); + const networkStatus = yield* SubscriptionRef.make<"unknown" | "offline" | "online">("online"); + const connectivity = Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + const profileStore = ConnectionProfileStore.of({ + get: (connectionId) => + Ref.update(profileReadCount, (count) => count + 1).pipe( + Effect.andThen(Ref.get(storedProfiles)), + Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), + ), + put: (profile) => + Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.set(profile.connectionId, profile); + return next; + }), + remove: (connectionId) => + Ref.update(storedProfiles, (current) => { + const next = new Map(current); + next.delete(connectionId); + return next; + }), + }); + const credentialStore = ConnectionCredentialStore.of({ + get: (connectionId) => + Ref.get(storedCredentials).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(connectionId))), + ), + put: (connectionId, credential) => + Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.set(connectionId, credential); + return next; + }), + remove: (connectionId) => + Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.delete(connectionId); + return next; + }), + }); + const tokenStore = RemoteDpopAccessTokenStore.of({ + get: (environmentId) => + Ref.get(storedRemoteTokens).pipe( + Effect.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + ), + put: (token) => + Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.set(token.environmentId, token); + return next; + }), + remove: (environmentId) => + Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + }); + const sshGateway = SshEnvironmentGateway.of({ + provision: () => Effect.die(new Error("SSH provisioning is not used.")), + prepare: () => Effect.die(new Error("SSH preparation is not used.")), + disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), + }); + const driver = ConnectionDriver.of({ + connect: (entry, reportProgress) => + Effect.gen(function* () { + const target = entry.target; + const prepared = { + ...PREPARED, + environmentId: target.environmentId, + label: target.label, + target, + }; + yield* reportProgress({ stage: "preparing" }); + yield* reportProgress({ stage: "opening", prepared }); + yield* options?.beforeSessionConnect?.(target.environmentId) ?? Effect.void; + const closed = yield* Deferred.make(); + yield* Ref.update(sessions, (current) => [...current, { closed }]); + const session = yield* Effect.acquireRelease( + Effect.succeed({ + client: {} as RpcSession["client"], + initialConfig: Effect.die(new Error("Config is not used by registry tests.")), + ready: Effect.void, + probe: Effect.void, + closed: Deferred.await(closed), + } satisfies RpcSession), + () => Effect.void, + ); + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session }; + }), + }); + + const cacheLayer = Layer.succeed(EnvironmentCacheStore, cacheStore); + const layer = environmentRegistryLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ConnectionTargetStore, targetStore), + Layer.succeed(ConnectionRegistrationStore, registrationStore), + Layer.succeed(ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore, credentialStore), + Layer.succeed(RemoteDpopAccessTokenStore, tokenStore), + Layer.succeed(SshEnvironmentGateway, sshGateway), + Layer.succeed(Connectivity, connectivity), + Layer.succeed(ConnectionWakeups, ConnectionWakeups.of({ changes: Stream.never })), + Layer.succeed(ConnectionDriver, driver), + cacheLayer, + Layer.succeed(EnvironmentOwnedDataCleanup, ownedDataCleanup), + ), + ), + ); + + return { + layer, + storedTargets, + shellCache, + cacheClears, + ownedDataClears, + sessions, + storedProfiles, + profileReadCount, + storedCredentials, + storedRemoteTokens, + disconnectedSshTargets, + networkStatus, + }; +}); + +function awaitConnectionState( + registry: EnvironmentRegistry["Service"], + environmentId: EnvironmentId, + predicate: (state: SupervisorConnectionState) => boolean, +) { + return Effect.gen(function* () { + const current = yield* registry.state(environmentId); + if (predicate(current)) { + return current; + } + return yield* registry + .stateChanges(environmentId) + .pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)); + }); +} + +describe("EnvironmentRegistry", () => { + it.effect("hydrates connection profiles into catalog entries", () => + Effect.gen(function* () { + const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const entry = (yield* SubscriptionRef.get(registry.entries)).get( + SSH_CONNECTION.environmentId, + ); + + expect(entry?.target).toEqual(SSH_CONNECTION); + expect(Option.getOrThrow(entry?.profile ?? Option.none())).toEqual(SSH_PROFILE); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("publishes network status changes independently of connection state", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const offline = yield* Effect.forkChild( + SubscriptionRef.changes(registry.networkStatus).pipe( + Stream.filter((status) => status === "offline"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + ); + + yield* SubscriptionRef.set(harness.networkStatus, "offline"); + + expect(yield* Fiber.join(offline)).toBe("offline"); + expect(yield* SubscriptionRef.get(registry.networkStatus)).toBe("offline"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts persisted environments independently", () => + Effect.gen(function* () { + const bothLoadsStarted = yield* Deferred.make(); + const releaseLoads = yield* Deferred.make(); + const loadCount = yield* Ref.make(0); + const harness = yield* makeHarness([TARGET, SECOND_TARGET], [], [], { + beforeSessionConnect: () => + Ref.updateAndGet(loadCount, (count) => count + 1).pipe( + Effect.tap((count) => + count === 2 ? Deferred.succeed(bothLoadsStarted, undefined) : Effect.void, + ), + Effect.andThen(Deferred.await(releaseLoads)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const start = yield* Effect.forkChild(registry.start); + + yield* Deferred.await(bothLoadsStarted).pipe(Effect.timeout("1 second")); + yield* Deferred.succeed(releaseLoads, undefined); + yield* Fiber.join(start); + + expect(yield* Ref.get(loadCount)).toBe(2); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("exposes the current RPC generation to late query subscribers", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const generation = yield* registry + .runStream( + TARGET.environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + Stream.concat( + Stream.fromEffect(SubscriptionRef.get(supervisor.state)), + SubscriptionRef.changes(supervisor.state), + ).pipe( + Stream.filterMap((state) => + state.phase === "connected" + ? Result.succeed(state.generation) + : Result.failVoid, + ), + Stream.changes, + ), + ), + ), + ), + ) + .pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + + expect(generation).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("preserves cached data on connection failure and clears it on explicit removal", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + const controls = yield* Ref.get(harness.sessions); + expect(controls).toHaveLength(1); + const active = controls[0]; + expect(active).toBeDefined(); + expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( + CACHED_SNAPSHOT, + ); + + const retryFiber = yield* Effect.forkChild( + awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "backoff", + ), + ); + yield* Effect.yieldNow; + yield* Deferred.fail( + active!.closed, + new ConnectionTransientError({ + reason: "transport", + message: "Disconnected.", + }), + ); + yield* Fiber.join(retryFiber); + expect((yield* Ref.get(harness.shellCache)).get(TARGET.environmentId)).toEqual( + CACHED_SNAPSHOT, + ); + + yield* registry.remove(TARGET.environmentId); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + expect((yield* Ref.get(harness.shellCache)).has(TARGET.environmentId)).toBe(false); + expect(yield* Ref.get(harness.cacheClears)).toEqual([TARGET.environmentId]); + expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe( + false, + ); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("persists and starts a newly registered environment", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.register(new RelayConnectionRegistration({ target: RELAY_TARGET })); + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect((yield* Ref.get(harness.storedTargets)).get(RELAY_TARGET.environmentId)).toEqual( + RELAY_TARGET, + ); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("moves durable streams to a replacement supervisor", () => + Effect.gen(function* () { + const replacement = new RelayConnectionTarget({ + environmentId: RELAY_TARGET.environmentId, + label: "Replacement relay environment", + }); + const harness = yield* makeHarness([RELAY_TARGET]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const firstObserved = yield* Deferred.make(); + const secondObserved = yield* Deferred.make(); + const labels = yield* Ref.make>([]); + yield* registry.start; + yield* awaitConnectionState( + registry, + RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const subscription = yield* Effect.forkChild( + registry + .followStream( + RELAY_TARGET.environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + Stream.concat(Stream.succeed(supervisor.target.label), Stream.never), + ), + ), + ), + ) + .pipe( + Stream.tap((label) => + Ref.updateAndGet(labels, (current) => [...current, label]).pipe( + Effect.flatMap((current) => + current.length === 1 + ? Deferred.succeed(firstObserved, undefined) + : Deferred.succeed(secondObserved, undefined), + ), + ), + ), + Stream.runDrain, + ), + ); + + yield* Deferred.await(firstObserved).pipe(Effect.timeout("1 second")); + yield* registry.register(new RelayConnectionRegistration({ target: replacement })); + yield* Deferred.await(secondObserved).pipe(Effect.timeout("1 second")); + yield* Fiber.interrupt(subscription); + + expect(yield* Ref.get(labels)).toEqual([RELAY_TARGET.label, replacement.label]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("ignores retry signals for environments that are no longer registered", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.retryNow(EnvironmentId.make("removed-environment")); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("removes all relay-owned data without touching non-cloud connections", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [RELAY_TARGET, SECOND_RELAY_TARGET, BEARER_TARGET], + [BEARER_PROFILE], + [[BEARER_TARGET.connectionId, BEARER_CREDENTIAL]], + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.removeRelayEnvironments(); + + const targets = yield* Ref.get(harness.storedTargets); + expect(targets.has(RELAY_TARGET.environmentId)).toBe(false); + expect(targets.has(SECOND_RELAY_TARGET.environmentId)).toBe(false); + expect(targets.get(BEARER_TARGET.environmentId)).toEqual(BEARER_TARGET); + expect(yield* Ref.get(harness.cacheClears)).toEqual( + expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), + ); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual( + expect.arrayContaining([RELAY_TARGET.environmentId, SECOND_RELAY_TARGET.environmentId]), + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).has(BEARER_TARGET.environmentId), + ).toBe(true); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts a newly paired bearer environment without re-reading its profile", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.register( + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + yield* awaitConnectionState( + registry, + BEARER_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect(yield* Ref.get(harness.profileReadCount)).toBe(0); + expect( + Option.getOrThrow( + (yield* SubscriptionRef.get(registry.entries)).get(BEARER_TARGET.environmentId) + ?.profile ?? Option.none(), + ), + ).toEqual(BEARER_PROFILE); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("starts platform environments without persisting or removing them", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(false); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + + const error = yield* Effect.flip(registry.remove(TARGET.environmentId)); + expect(error._tag).toBe("PlatformEnvironmentRemovalError"); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, + ).toEqual(TARGET); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("does not reacquire a runtime while its registration is being removed", () => + Effect.gen(function* () { + const removalStarted = yield* Deferred.make(); + const continueRemoval = yield* Deferred.make(); + const harness = yield* makeHarness([TARGET], [], [], { + beforeRegistrationRemove: () => + Deferred.succeed(removalStarted, undefined).pipe( + Effect.andThen(Deferred.await(continueRemoval)), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const removal = yield* Effect.forkChild(registry.remove(TARGET.environmentId)); + yield* Deferred.await(removalStarted); + + const stateLookup = yield* Effect.forkChild( + Effect.flip(registry.state(TARGET.environmentId)), + ); + yield* Effect.yieldNow; + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + + yield* Deferred.succeed(continueRemoval, undefined); + yield* Fiber.join(removal); + const error = yield* Fiber.join(stateLookup); + expect(error._tag).toBe("EnvironmentNotRegisteredError"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("retains a healthy runtime when the platform repeats an identical registration", () => + Effect.gen(function* () { + const harness = yield* makeHarness([]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + const registration = new PrimaryConnectionRegistration({ target: TARGET }); + yield* registry.registerPlatform(registration); + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + yield* registry.registerPlatform(registration); + + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("removes all owned SSH state only on explicit removal", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [SSH_CONNECTION], + [SSH_PROFILE], + [ + [ + SSH_CONNECTION.connectionId, + new BearerConnectionCredential({ token: "temporary-token" }), + ], + ], + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry; + yield* registry.start; + yield* registry.remove(SSH_CONNECTION.environmentId); + + expect((yield* Ref.get(harness.storedProfiles)).has(SSH_CONNECTION.connectionId)).toBe( + false, + ); + expect((yield* Ref.get(harness.storedCredentials)).has(SSH_CONNECTION.connectionId)).toBe( + false, + ); + expect((yield* Ref.get(harness.storedRemoteTokens)).has(SSH_CONNECTION.environmentId)).toBe( + false, + ); + expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([SSH_TARGET]); + }).pipe(Effect.provide(harness.layer)); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts new file mode 100644 index 00000000000..f165cc4bb42 --- /dev/null +++ b/packages/client-runtime/src/connection/registry.ts @@ -0,0 +1,514 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { SshEnvironmentGateway } from "../platform/capabilities.ts"; +import { + type ConnectionCatalogEntry, + type ConnectionRegistration, + ConnectionProfileStore, + type PrimaryConnectionRegistration, + SshConnectionProfile, + connectionRegistrationCatalogEntry, +} from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; +import type { ConnectionAttemptError } from "./model.ts"; +import { + type ConnectionPersistenceError, + ConnectionRegistrationStore, + ConnectionTargetStore, + EnvironmentCacheStore, + EnvironmentOwnedDataCleanup, +} from "../platform/persistence.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, + makeEnvironmentSupervisor, +} from "./supervisor.ts"; +import { ConnectionDriver } from "./driver.ts"; +import { ConnectionWakeups } from "./wakeups.ts"; + +const isSshConnectionProfile = Schema.is(SshConnectionProfile); + +export class EnvironmentNotRegisteredError extends Schema.TaggedErrorClass()( + "EnvironmentNotRegisteredError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass()( + "PlatformEnvironmentRemovalError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export interface EnvironmentRegistryService { + readonly entries: SubscriptionRef.SubscriptionRef< + ReadonlyMap + >; + readonly networkStatus: SubscriptionRef.SubscriptionRef; + readonly start: Effect.Effect; + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; + readonly remove: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + | ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | PlatformEnvironmentRemovalError + >; + readonly removeRelayEnvironments: () => Effect.Effect< + void, + ConnectionPersistenceError | ConnectionAttemptError | PlatformEnvironmentRemovalError + >; + readonly retryNow: (environmentId: EnvironmentId) => Effect.Effect; + readonly state: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly stateChanges: ( + environmentId: EnvironmentId, + ) => Stream.Stream; + readonly run: ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ) => Effect.Effect>; + readonly runStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream>; + readonly followStream: ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => Stream.Stream>; +} + +export class EnvironmentRegistry extends Context.Service< + EnvironmentRegistry, + EnvironmentRegistryService +>()("@t3tools/client-runtime/connection/registry/EnvironmentRegistry") {} + +interface EnvironmentServiceScope { + readonly entry: ConnectionCatalogEntry; + readonly supervisor: EnvironmentSupervisorService; + readonly scope: Scope.Closeable; +} + +const makeEnvironmentRegistry = Effect.fn("EnvironmentRegistry.make")(function* () { + const storage = yield* ConnectionTargetStore; + const registrations = yield* ConnectionRegistrationStore; + const cache = yield* EnvironmentCacheStore; + const ownedDataCleanup = yield* EnvironmentOwnedDataCleanup; + const profiles = yield* ConnectionProfileStore; + const connectivity = yield* Connectivity; + const driver = yield* ConnectionDriver; + const wakeups = yield* ConnectionWakeups; + const ssh = yield* SshEnvironmentGateway; + const persistedTargets = yield* storage.list; + const initialEntries = new Map( + yield* Effect.forEach( + persistedTargets, + Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) { + const profile = + target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" + ? yield* profiles.get(target.connectionId) + : Option.none(); + return [ + target.environmentId, + { target, profile } satisfies ConnectionCatalogEntry, + ] as const; + }), + { concurrency: "unbounded" }, + ), + ); + const entries = + yield* SubscriptionRef.make>(initialEntries); + const networkStatus = yield* SubscriptionRef.make(yield* connectivity.status); + const serviceScopes = yield* SubscriptionRef.make< + ReadonlyMap + >(new Map()); + const platformEnvironmentIds = yield* Ref.make>(new Set()); + interface LeaseLock { + readonly semaphore: Semaphore.Semaphore; + readonly users: number; + } + + const leaseLocks = yield* Ref.make>(new Map()); + const leaseLocksGuard = yield* Semaphore.make(1); + const started = yield* Ref.make(false); + + const withLeaseLock = ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ): Effect.Effect => + Effect.acquireUseRelease( + leaseLocksGuard.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(leaseLocks); + const existing = current.get(environmentId); + if (existing !== undefined) { + yield* Ref.set( + leaseLocks, + new Map(current).set(environmentId, { + semaphore: existing.semaphore, + users: existing.users + 1, + }), + ); + return existing.semaphore; + } + const semaphore = yield* Semaphore.make(1); + yield* Ref.set(leaseLocks, new Map(current).set(environmentId, { semaphore, users: 1 })); + return semaphore; + }), + ), + (semaphore) => semaphore.withPermits(1)(effect), + (semaphore) => + leaseLocksGuard.withPermits(1)( + Ref.update(leaseLocks, (current) => { + const existing = current.get(environmentId); + if (existing === undefined || existing.semaphore !== semaphore) { + return current; + } + const next = new Map(current); + if (existing.users === 1) { + next.delete(environmentId); + } else { + next.set(environmentId, { + semaphore, + users: existing.users - 1, + }); + } + return next; + }), + ), + ).pipe(Effect.withSpan("EnvironmentRegistry.withLeaseLock")); + + const getEntry = Effect.fn("EnvironmentRegistry.getEntry")(function* ( + environmentId: EnvironmentId, + ) { + const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + if (entry === undefined) { + return yield* new EnvironmentNotRegisteredError({ + environmentId, + message: `Environment ${environmentId} is not registered.`, + }); + } + return entry; + }); + + const closeServiceScope = Effect.fn("EnvironmentRegistry.closeServiceScope")(function* ( + environmentId: EnvironmentId, + ) { + const current = yield* SubscriptionRef.get(serviceScopes); + const lease = current.get(environmentId); + if (lease === undefined) { + return; + } + const next = new Map(current); + next.delete(environmentId); + yield* SubscriptionRef.set(serviceScopes, next); + yield* Scope.close(lease.scope, Exit.void); + }); + + const createServiceScope = Effect.fn("EnvironmentRegistry.createServiceScope")( + (entry: ConnectionCatalogEntry) => + Effect.uninterruptible( + Effect.gen(function* () { + const environmentId = entry.target.environmentId; + const scope = yield* Scope.make(); + const supervisor = yield* makeEnvironmentSupervisor(entry, { + initiallyDesired: false, + }).pipe( + Effect.provideService(Connectivity, connectivity), + Effect.provideService(ConnectionDriver, driver), + Effect.provideService(ConnectionWakeups, wakeups), + Scope.provide(scope), + Effect.onError(() => Scope.close(scope, Exit.void)), + ); + yield* supervisor.connect; + yield* SubscriptionRef.update(serviceScopes, (current) => { + const next = new Map(current); + next.set(environmentId, { entry, supervisor, scope }); + return next; + }); + return supervisor; + }), + ), + ); + + const acquireSupervisor = Effect.fn("EnvironmentRegistry.acquireSupervisor")(function* ( + environmentId: EnvironmentId, + ) { + return yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const entry = yield* getEntry(environmentId); + const existing = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + if (existing !== undefined) { + if (Equal.equals(existing.entry, entry)) { + return existing.supervisor; + } + yield* closeServiceScope(environmentId); + } + return yield* createServiceScope(entry); + }), + ); + }); + + const run: EnvironmentRegistryService["run"] = Effect.fn("EnvironmentRegistry.run")(function* < + A, + E, + R, + >(environmentId: EnvironmentId, effect: Effect.Effect) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* Effect.provideService(effect, EnvironmentSupervisor, supervisor); + }); + + const runStream: EnvironmentRegistryService["runStream"] = ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => + Stream.unwrap( + acquireSupervisor(environmentId).pipe( + Effect.map((supervisor) => + Stream.provideService(stream, EnvironmentSupervisor, supervisor), + ), + ), + ); + + const followStream: EnvironmentRegistryService["followStream"] = ( + environmentId: EnvironmentId, + stream: Stream.Stream, + ) => + Stream.concat( + Stream.fromEffect(SubscriptionRef.get(entries)), + SubscriptionRef.changes(entries), + ).pipe( + Stream.map((current) => Option.fromUndefinedOr(current.get(environmentId))), + Stream.changes, + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: () => + Stream.unwrap( + acquireSupervisor(environmentId).pipe( + Effect.match({ + onFailure: () => Stream.empty, + onSuccess: (supervisor) => + Stream.provideService(stream, EnvironmentSupervisor, supervisor), + }), + ), + ), + }), + ), + ); + + const start = Effect.gen(function* () { + if (yield* Ref.getAndSet(started, true)) { + return; + } + yield* Effect.forEach( + persistedTargets, + (target) => + acquireSupervisor(target.environmentId).pipe( + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + ), + { + concurrency: "unbounded", + discard: true, + }, + ); + }).pipe(Effect.withSpan("EnvironmentRegistry.start")); + + const installEntry = Effect.fn("EnvironmentRegistry.installEntry")(function* ( + entry: ConnectionCatalogEntry, + options?: { readonly retainEquivalentRuntime?: boolean }, + ) { + const target = entry.target; + yield* withLeaseLock( + target.environmentId, + Effect.gen(function* () { + const previous = (yield* SubscriptionRef.get(entries)).get(target.environmentId); + const existingScope = (yield* SubscriptionRef.get(serviceScopes)).get(target.environmentId); + if ( + options?.retainEquivalentRuntime === true && + previous !== undefined && + Equal.equals(previous, entry) && + existingScope !== undefined && + Equal.equals(existingScope.entry, entry) + ) { + return; + } + + yield* closeServiceScope(target.environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.set(target.environmentId, entry); + return next; + }); + yield* createServiceScope(entry); + }), + ); + }); + + const register = Effect.fn("EnvironmentRegistry.register")(function* ( + registration: ConnectionRegistration, + ) { + yield* registrations.register(registration); + yield* installEntry(connectionRegistrationCatalogEntry(registration)); + }); + + const registerPlatform = Effect.fn("EnvironmentRegistry.registerPlatform")(function* ( + registration: PrimaryConnectionRegistration, + ) { + const entry = connectionRegistrationCatalogEntry(registration); + const target = entry.target; + yield* Ref.update(platformEnvironmentIds, (current) => { + const next = new Set(current); + next.add(target.environmentId); + return next; + }); + yield* installEntry(entry, { retainEquivalentRuntime: true }); + }); + + const remove = Effect.fn("EnvironmentRegistry.remove")(function* (environmentId: EnvironmentId) { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + return yield* new PlatformEnvironmentRemovalError({ + environmentId, + message: "Platform-managed environments cannot be removed.", + }); + } + return yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const target = (yield* getEntry(environmentId)).target; + const profile = + target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" + ? yield* profiles.get(target.connectionId) + : Option.none(); + + if ( + target._tag === "SshConnectionTarget" && + Option.isSome(profile) && + isSshConnectionProfile(profile.value) + ) { + yield* ssh.disconnect(profile.value.target).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not disconnect the managed SSH environment.", { + environmentId, + error, + }), + ), + Effect.ignore, + ); + } + + yield* registrations.remove(target); + yield* closeServiceScope(environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* Effect.all([cache.clear(environmentId), ownedDataCleanup.clear(environmentId)], { + concurrency: "unbounded", + discard: true, + }); + }), + ); + }); + + const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( + function* () { + const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] + .filter((entry) => entry.target._tag === "RelayConnectionTarget") + .map((entry) => entry.target.environmentId); + + yield* Effect.forEach( + relayEnvironmentIds, + (environmentId) => + remove(environmentId).pipe( + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + ), + { + concurrency: "unbounded", + discard: true, + }, + ); + }, + ); + + const retryNow = (environmentId: EnvironmentId) => + acquireSupervisor(environmentId).pipe( + Effect.flatMap((supervisor) => supervisor.retryNow), + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + Effect.withSpan("EnvironmentRegistry.retryNow"), + ); + const state = Effect.fn("EnvironmentRegistry.state")(function* (environmentId: EnvironmentId) { + const supervisor = yield* acquireSupervisor(environmentId); + return yield* SubscriptionRef.get(supervisor.state); + }); + const stateChanges = (environmentId: EnvironmentId) => + followStream( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), + ), + ), + ); + + yield* Effect.addFinalizer(() => + SubscriptionRef.get(serviceScopes).pipe( + Effect.flatMap((current) => + Effect.forEach(current.values(), (lease) => Scope.close(lease.scope, Exit.void), { + concurrency: "unbounded", + discard: true, + }), + ), + ), + ); + yield* connectivity.changes.pipe( + Stream.runForEach((status) => SubscriptionRef.set(networkStatus, status)), + Effect.forkScoped, + ); + + return EnvironmentRegistry.of({ + entries, + networkStatus, + start, + register, + registerPlatform, + remove, + removeRelayEnvironments, + retryNow, + state, + stateChanges, + run, + runStream, + followStream, + }); +}); + +export const environmentRegistryLayer = Layer.effect( + EnvironmentRegistry, + makeEnvironmentRegistry(), +); diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts new file mode 100644 index 00000000000..31f75bf4bdc --- /dev/null +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -0,0 +1,423 @@ +import { EnvironmentId, type DesktopSshEnvironmentTarget } from "@t3tools/contracts"; +import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Tracer from "effect/Tracer"; + +import { + ManagedRelayClient, + ManagedRelayClientError, + ManagedRelayRequestTimeoutError, +} from "../relay/managedRelay.ts"; +import { ConnectionResolver } from "./resolver.ts"; +import { connectionResolverLayer } from "./resolver.ts"; +import { + CloudSession, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "../platform/capabilities.ts"; +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + type ConnectionCatalogEntry, + ConnectionCredentialStore, + ConnectionProfileStore, + SshConnectionProfile, + type ConnectionCredential, + type ConnectionProfile, +} from "./catalog.ts"; +import { + BearerConnectionTarget, + ConnectionTransientError, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, + type ConnectionTarget, +} from "./model.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const ENDPOINT = { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel" as const, +}; +const SSH_TARGET: DesktopSshEnvironmentTarget = { + alias: "development", + hostname: "development.example.test", + username: "developer", + port: 22, +}; + +function catalogEntry( + target: ConnectionTarget, + profile: Option.Option = Option.none(), +): ConnectionCatalogEntry { + return { target, profile }; +} + +function unsupported(name: string): Effect.Effect { + return Effect.die(new Error(`Unexpected relay call: ${name}`)); +} + +function collectingTracer(spans: Array): Tracer.Tracer { + return 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; + }, + }); +} + +function relayClient(connectEnvironment: ManagedRelayClient["Service"]["connectEnvironment"]) { + return ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => unsupported("listEnvironments"), + listDevices: () => unsupported("listDevices"), + createEnvironmentLinkChallenge: () => unsupported("createEnvironmentLinkChallenge"), + linkEnvironment: () => unsupported("linkEnvironment"), + unlinkEnvironment: () => unsupported("unlinkEnvironment"), + getEnvironmentStatus: () => unsupported("getEnvironmentStatus"), + connectEnvironment, + registerDevice: () => unsupported("registerDevice"), + unregisterDevice: () => unsupported("unregisterDevice"), + registerLiveActivity: () => unsupported("registerLiveActivity"), + resetTokenCache: Effect.void, + }); +} + +const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((options?: { + readonly profiles?: ReadonlyArray; + readonly credentials?: ReadonlyArray; + readonly connectEnvironment?: ManagedRelayClient["Service"]["connectEnvironment"]; + readonly authorizeBearer?: RemoteEnvironmentAuthorization["Service"]["authorizeBearer"]; + readonly authorizeDpop?: RemoteEnvironmentAuthorization["Service"]["authorizeDpop"]; + readonly prepareSsh?: SshEnvironmentGateway["Service"]["prepare"]; +}) => { + const profiles = new Map( + (options?.profiles ?? []).map((profile) => [profile.connectionId, profile]), + ); + const credentials = new Map(options?.credentials ?? []); + + const profileStore = ConnectionProfileStore.of({ + get: (connectionId) => Effect.succeed(Option.fromNullishOr(profiles.get(connectionId))), + put: (profile) => Effect.sync(() => void profiles.set(profile.connectionId, profile)), + remove: (connectionId) => Effect.sync(() => void profiles.delete(connectionId)), + }); + const credentialStore = ConnectionCredentialStore.of({ + get: (connectionId) => Effect.succeed(Option.fromNullishOr(credentials.get(connectionId))), + put: (connectionId, credential) => + Effect.sync(() => void credentials.set(connectionId, credential)), + remove: (connectionId) => Effect.sync(() => void credentials.delete(connectionId)), + }); + const remote = RemoteEnvironmentAuthorization.of({ + authorizeBearer: + options?.authorizeBearer ?? + ((input) => + Effect.succeed({ + environmentId: input.expectedEnvironmentId, + label: "Authorized bearer environment", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "wss://authorized.example.test/ws?wsTicket=bearer", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + })), + authorizeDpop: + options?.authorizeDpop ?? + ((input) => + input.obtainBootstrap.pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Authorized relay environment", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://authorized.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + )), + }); + const ssh = SshEnvironmentGateway.of({ + provision: () => Effect.die("unused"), + prepare: + options?.prepareSsh ?? + (() => + Effect.succeed({ + bootstrap: { + target: SSH_TARGET, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: null, + }, + bearerToken: "ssh-bearer", + })), + disconnect: () => Effect.void, + }); + + const dependencies = Layer.mergeAll( + Layer.succeed(ConnectionProfileStore, profileStore), + Layer.succeed(ConnectionCredentialStore, credentialStore), + Layer.succeed(CloudSession, CloudSession.of({ clerkToken: Effect.succeed("clerk-session") })), + Layer.succeed( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ deviceId: Effect.succeed(Option.some("device-1")) }), + ), + Layer.succeed(RemoteEnvironmentAuthorization, remote), + Layer.succeed(SshEnvironmentGateway, ssh), + Layer.succeed( + ManagedRelayClient, + relayClient( + options?.connectEnvironment ?? + ((input) => + Effect.succeed({ + environmentId: input.environmentId, + endpoint: ENDPOINT, + credential: "relay-bootstrap", + expiresAt: "2026-06-06T00:00:00.000Z", + })), + ), + ), + ); + + return Effect.succeed(connectionResolverLayer.pipe(Layer.provide(dependencies))); +}); + +describe("ConnectionResolver", () => { + it.effect("prepares a primary environment without remote capabilities", () => + Effect.gen(function* () { + const brokerLayer = yield* makeDependencies(); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const target = new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + wsBaseUrl: "ws://127.0.0.1:3777", + }); + + expect(yield* broker.prepare(catalogEntry(target))).toEqual({ + environmentId: ENVIRONMENT_ID, + label: "Primary", + httpBaseUrl: "http://127.0.0.1:3777", + socketUrl: "ws://127.0.0.1:3777/ws", + httpAuthorization: null, + target, + }); + }), + ); + + it.effect("uses the registered bearer profile without re-reading the profile store", () => + Effect.gen(function* () { + const bearerInputs = yield* Ref.make>([]); + const target = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Saved", + connectionId: "saved-1", + }); + const profile = new BearerConnectionProfile({ + connectionId: "saved-1", + environmentId: ENVIRONMENT_ID, + label: "Saved", + httpBaseUrl: ENDPOINT.httpBaseUrl, + wsBaseUrl: ENDPOINT.wsBaseUrl, + }); + const brokerLayer = yield* makeDependencies({ + credentials: [["saved-1", new BearerConnectionCredential({ token: "secret-bearer" })]], + authorizeBearer: (input) => + Ref.update(bearerInputs, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Saved", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=ticket", + httpAuthorization: { + _tag: "Bearer" as const, + token: input.bearerToken, + }, + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect( + (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, + ).toContain("wsTicket=ticket"); + expect(yield* Ref.get(bearerInputs)).toEqual(["secret-bearer"]); + }), + ); + + it.effect("brokers relay credentials with the current cloud session and device identity", () => + Effect.gen(function* () { + const relayInputs = yield* Ref.make< + ReadonlyArray<{ + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly deviceId?: string; + }> + >([]); + const bootstrapCredentials = yield* Ref.make>([]); + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + connectEnvironment: (input) => + Ref.update(relayInputs, (values) => [ + ...values, + { + clerkToken: input.clerkToken, + scopes: input.scopes, + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + }, + ]).pipe( + Effect.as({ + environmentId: input.environmentId, + endpoint: ENDPOINT, + credential: "relay-bootstrap", + expiresAt: "2026-06-06T00:00:00.000Z", + }), + ), + authorizeDpop: (input) => + input.obtainBootstrap.pipe( + Effect.tap((bootstrap) => + Ref.update(bootstrapCredentials, (values) => [...values, bootstrap.credential]), + ), + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Cloud", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect((yield* broker.prepare(catalogEntry(target))).socketUrl).toContain("wsTicket=dpop"); + expect(yield* Ref.get(relayInputs)).toEqual([ + { + clerkToken: "clerk-session", + scopes: [RelayEnvironmentConnectScope], + deviceId: "device-1", + }, + ]); + expect(yield* Ref.get(bootstrapCredentials)).toEqual(["relay-bootstrap"]); + }), + ); + + it.effect("exports the complete relay authorization flow through the product tracer", () => + Effect.gen(function* () { + const userSpans: Array = []; + const productSpans: Array = []; + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + authorizeDpop: (input) => + input.obtainBootstrap.pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "Cloud", + httpBaseUrl: ENDPOINT.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=dpop", + httpAuthorization: { + _tag: "Dpop" as const, + accessToken: "dpop-access-token", + }, + }), + Effect.withSpan("test.remote.authorizeDpop"), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + yield* broker + .prepare(catalogEntry(target)) + .pipe( + Effect.provideService(RelayClientTracer, Option.some(collectingTracer(productSpans))), + Effect.withTracer(collectingTracer(userSpans)), + ); + + expect(productSpans).toContain("clientRuntime.connection.broker.relay"); + expect(productSpans).toContain("test.remote.authorizeDpop"); + expect(userSpans).toContain("clientRuntime.connection.broker.prepare"); + expect(userSpans).not.toContain("test.remote.authorizeDpop"); + }), + ); + + it.effect("delegates SSH launch to the platform gateway before remote authorization", () => + Effect.gen(function* () { + const preparedTargets = yield* Ref.make>([]); + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: "ssh-1", + environmentId: ENVIRONMENT_ID, + label: "SSH", + target: SSH_TARGET, + }); + const brokerLayer = yield* makeDependencies({ + prepareSsh: (input) => + Ref.update(preparedTargets, (values) => [...values, input.target]).pipe( + Effect.as({ + bootstrap: { + target: input.target, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: null, + }, + bearerToken: "ssh-bearer", + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + expect( + (yield* broker.prepare(catalogEntry(target, Option.some(profile)))).socketUrl, + ).toContain("wsTicket=bearer"); + expect(yield* Ref.get(preparedTargets)).toEqual([SSH_TARGET]); + }), + ); + + it.effect("classifies relay request timeouts as retryable connection failures", () => + Effect.gen(function* () { + const target = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Cloud", + }); + const brokerLayer = yield* makeDependencies({ + connectEnvironment: () => + Effect.fail( + new ManagedRelayClientError({ + message: "Relay timed out.", + cause: new ManagedRelayRequestTimeoutError({ + message: "Relay timed out.", + }), + }), + ), + }); + const broker = yield* ConnectionResolver.pipe(Effect.provide(brokerLayer)); + const error = yield* Effect.flip(broker.prepare(catalogEntry(target))); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ reason: "timeout" }); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts new file mode 100644 index 00000000000..6eb0027e3a8 --- /dev/null +++ b/packages/client-runtime/src/connection/resolver.ts @@ -0,0 +1,257 @@ +import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +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 Schema from "effect/Schema"; + +import { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; +import { ManagedRelayClient } from "../relay/managedRelay.ts"; +import { + CloudSession, + RelayDeviceIdentity, + SshEnvironmentGateway, +} from "../platform/capabilities.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + type ConnectionCatalogEntry, + ConnectionCredentialStore, + ConnectionProfileStore, + SshConnectionProfile, +} from "./catalog.ts"; +import { + credentialMissingError, + environmentMismatchError, + mapManagedRelayError, + profileMissingError, +} from "./errors.ts"; +import type { + BearerConnectionTarget, + ConnectionTarget, + PreparedConnection, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "./model.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts"; + +export class ConnectionResolver extends Context.Service< + ConnectionResolver, + { + readonly prepare: ( + entry: ConnectionCatalogEntry, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/connection/resolver/ConnectionResolver") {} + +const isBearerProfile = Schema.is(BearerConnectionProfile); +const isSshProfile = Schema.is(SshConnectionProfile); +const isBearerCredential = Schema.is(BearerConnectionCredential); + +function primarySocketUrl(target: PrimaryConnectionTarget): string { + const url = new URL(target.wsBaseUrl); + if (url.pathname === "" || url.pathname === "/") { + url.pathname = "/ws"; + } + return url.toString(); +} + +const primaryBroker = Effect.fn("clientRuntime.connection.broker.primary")( + (target: PrimaryConnectionTarget) => + Effect.succeed({ + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: target.httpBaseUrl, + socketUrl: primarySocketUrl(target), + httpAuthorization: null, + target, + } satisfies PreparedConnection), +); + +const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer")(function* () { + const credentials = yield* ConnectionCredentialStore; + const remote = yield* RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.bearer")(function* ( + entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, + ) { + const target = entry.target; + const profile = yield* Option.match(entry.profile, { + onNone: () => Effect.fail(profileMissingError(target.connectionId)), + onSome: Effect.succeed, + }); + if (!isBearerProfile(profile)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: `Connection profile ${target.connectionId} is not a bearer connection.`, + }); + } + if (profile.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: profile.environmentId, + }); + } + const credential = yield* credentials.get(target.connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(credentialMissingError(target.connectionId)), + onSome: Effect.succeed, + }), + ), + ); + if (!isBearerCredential(credential)) { + return yield* credentialMissingError(target.connectionId); + } + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: profile.httpBaseUrl, + wsBaseUrl: profile.wsBaseUrl, + bearerToken: credential.token, + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }); +}); + +const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(function* () { + const relay = yield* ManagedRelayClient; + const session = yield* CloudSession; + const identity = yield* RelayDeviceIdentity; + const remote = yield* RemoteEnvironmentAuthorization; + + return Effect.fnUntraced( + function* (target: RelayConnectionTarget) { + const authorized = yield* remote.authorizeDpop({ + expectedEnvironmentId: target.environmentId, + obtainBootstrap: Effect.gen(function* () { + const clerkToken = yield* session.clerkToken.pipe( + Effect.withSpan("relay.connection.cloudSessionToken.resolve"), + ); + const deviceId = yield* identity.deviceId.pipe( + Effect.withSpan("relay.connection.deviceIdentity.resolve"), + ); + const connected = yield* relay + .connectEnvironment({ + clerkToken, + scopes: [RelayEnvironmentConnectScope], + environmentId: target.environmentId, + ...(Option.isSome(deviceId) ? { deviceId: deviceId.value } : {}), + }) + .pipe(Effect.mapError(mapManagedRelayError)); + if (connected.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: connected.environmentId, + }); + } + return connected; + }).pipe(Effect.withSpan("relay.connection.bootstrap.obtain")), + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }, + Effect.withSpan("clientRuntime.connection.broker.relay"), + withRelayClientTracing, + ); +}); + +const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { + const profiles = yield* ConnectionProfileStore; + const ssh = yield* SshEnvironmentGateway; + const remote = yield* RemoteEnvironmentAuthorization; + + return Effect.fn("clientRuntime.connection.broker.ssh")(function* ( + entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, + ) { + const target = entry.target; + const profile = yield* Option.match(entry.profile, { + onNone: () => Effect.fail(profileMissingError(target.connectionId)), + onSome: Effect.succeed, + }); + if (!isSshProfile(profile)) { + return yield* new ConnectionBlockedError({ + reason: "configuration", + message: `Connection profile ${target.connectionId} is not an SSH connection.`, + }); + } + if (profile.environmentId !== target.environmentId) { + return yield* environmentMismatchError({ + expected: target.environmentId, + actual: profile.environmentId, + }); + } + const prepared = yield* ssh.prepare({ + connectionId: target.connectionId, + expectedEnvironmentId: target.environmentId, + target: profile.target, + }); + yield* profiles.put( + new SshConnectionProfile({ + connectionId: profile.connectionId, + environmentId: profile.environmentId, + label: profile.label, + target: prepared.bootstrap.target, + }), + ); + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: prepared.bootstrap.httpBaseUrl, + wsBaseUrl: prepared.bootstrap.wsBaseUrl, + bearerToken: prepared.bearerToken, + }); + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }); +}); + +export const connectionResolverLayer = Layer.effect( + ConnectionResolver, + Effect.gen(function* () { + const bearer = yield* makeBearerBroker(); + const relay = yield* makeRelayBroker(); + const ssh = yield* makeSshBroker(); + + const prepare = Effect.fn("clientRuntime.connection.broker.prepare")(function* ( + entry: ConnectionCatalogEntry, + ) { + const target: ConnectionTarget = entry.target; + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": target.environmentId, + "connection.target.kind": target._tag, + }); + switch (target._tag) { + case "PrimaryConnectionTarget": + return yield* primaryBroker(target); + case "BearerConnectionTarget": + return yield* bearer({ ...entry, target }); + case "RelayConnectionTarget": + return yield* relay(target); + case "SshConnectionTarget": + return yield* ssh({ ...entry, target }); + } + }); + + return ConnectionResolver.of({ prepare }); + }), +); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts new file mode 100644 index 00000000000..9ef35348ab2 --- /dev/null +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -0,0 +1,806 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayClientTracer } from "@t3tools/shared/relayTracing"; +import { describe, expect, it } from "@effect/vitest"; +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 Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import { + ConnectionDriver, + type ConnectionDriverProgress, + type EnvironmentConnectionLease, +} from "./driver.ts"; +import { + ConnectionBlockedError, + ConnectionTransientError, + PrimaryConnectionTarget, + RelayConnectionTarget, + type ConnectionAttemptError, + type ConnectionTarget, + type NetworkStatus, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { makeEnvironmentSupervisor } from "./supervisor.ts"; +import { ConnectionWakeups } from "./wakeups.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const RELAY_TARGET = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, +}); + +const TARGET_ENTRY: ConnectionCatalogEntry = { + target: TARGET, + profile: Option.none(), +}; + +const RELAY_ENTRY: ConnectionCatalogEntry = { + target: RELAY_TARGET, + profile: Option.none(), +}; + +const PREPARED_CONNECTION: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws", + httpAuthorization: null, + target: TARGET, +}; + +const TEST_RPC_CLIENT = {} as WsRpcProtocolClient; + +function transient(message = "Connection failed.") { + return new ConnectionTransientError({ + reason: "transport", + message, + }); +} + +function blocked(message = "Authentication required.") { + return new ConnectionBlockedError({ + reason: "authentication", + message, + }); +} + +function awaitState( + state: SubscriptionRef.SubscriptionRef, + predicate: (value: SupervisorConnectionState) => boolean, +) { + return SubscriptionRef.changes(state).pipe( + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); +} + +const eventuallyState = Effect.fn("TestConnectionHarness.eventuallyState")(function* ( + state: SubscriptionRef.SubscriptionRef, + predicate: (value: SupervisorConnectionState) => boolean, +) { + let lastState = yield* SubscriptionRef.get(state); + for (let iteration = 0; iteration < 100; iteration += 1) { + lastState = yield* SubscriptionRef.get(state); + if (predicate(lastState)) { + return lastState; + } + yield* Effect.yieldNow; + } + return yield* Effect.die( + new Error( + `Expected supervisor state was not observed. Last state: phase=${lastState.phase}, stage=${lastState.stage ?? "none"}, attempt=${lastState.attempt}, generation=${lastState.generation}`, + ), + ); +}); + +const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: { + readonly networkStatus?: NetworkStatus; + readonly prepare?: ( + attempt: number, + target: ConnectionTarget, + ) => Effect.Effect; + readonly ready?: (attempt: number) => Effect.Effect; + readonly probe?: (attempt: number) => Effect.Effect; +}) { + const networkStatus = yield* SubscriptionRef.make( + options?.networkStatus ?? "online", + ); + const prepareCount = yield* Ref.make(0); + const sessionCount = yield* Ref.make(0); + const releaseCount = yield* Ref.make(0); + const wakeups = yield* SubscriptionRef.make<{ + readonly sequence: number; + readonly reason: "application-active" | "credentials-changed"; + }>({ + sequence: 0, + reason: "application-active", + }); + const closedSessions = yield* Ref.make< + ReadonlyArray> + >([]); + + const connectivity = Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + + const prepare = Effect.fn("TestConnectionDriver.prepare")(function* (target: ConnectionTarget) { + const attempt = yield* Ref.updateAndGet(prepareCount, (count) => count + 1); + if (options?.prepare) { + return yield* options.prepare(attempt, target); + } + return PREPARED_CONNECTION; + }); + + const connect = Effect.fn("TestConnectionDriver.connect")(function* ( + entry: ConnectionCatalogEntry, + reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, + ) { + const target = entry.target; + yield* reportProgress({ stage: "preparing" }); + const prepared = yield* prepare(target); + yield* reportProgress({ stage: "opening", prepared }); + + const attempt = yield* Ref.updateAndGet(sessionCount, (count) => count + 1); + const closed = yield* Deferred.make(); + yield* Ref.update(closedSessions, (sessions) => [...sessions, closed]); + + const session = yield* Effect.acquireRelease( + Effect.succeed({ + client: TEST_RPC_CLIENT, + initialConfig: Effect.die(new Error("Initial config is not used by supervisor tests.")), + ready: options?.ready?.(attempt) ?? Effect.void, + probe: options?.probe?.(attempt) ?? Effect.void, + closed: Deferred.await(closed), + } satisfies RpcSession), + () => Ref.update(releaseCount, (count) => count + 1), + ); + + yield* reportProgress({ stage: "synchronizing", prepared }); + yield* session.ready; + return { prepared, session } satisfies EnvironmentConnectionLease; + }); + + const dependencies = Layer.mergeAll( + Layer.succeed(Connectivity, connectivity), + Layer.succeed( + ConnectionWakeups, + ConnectionWakeups.of({ + changes: SubscriptionRef.changes(wakeups).pipe( + Stream.drop(1), + Stream.map((event) => event.reason), + ), + }), + ), + Layer.succeed(ConnectionDriver, ConnectionDriver.of({ connect })), + ); + + return { + dependencies, + prepareCount, + sessionCount, + releaseCount, + setNetworkStatus: (status: NetworkStatus) => SubscriptionRef.set(networkStatus, status), + wake: (reason: "application-active" | "credentials-changed") => + SubscriptionRef.update(wakeups, (event) => ({ + sequence: event.sequence + 1, + reason, + })), + closeLatestSession: Effect.fn("TestConnectionHarness.closeLatestSession")(function* ( + error = transient("Session closed."), + ) { + const sessions = yield* Ref.get(closedSessions); + const latest = sessions.at(-1); + if (latest) { + yield* Deferred.fail(latest, error); + } + }), + }; +}); + +describe("EnvironmentSupervisor", () => { + it.effect("exports each relay setup as a standalone linked trace that ends at readiness", () => + Effect.gen(function* () { + const spans: Array = []; + const tracer = 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); + }; + return span; + }, + }); + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(RELAY_ENTRY, { + initiallyDesired: true, + }).pipe( + Effect.provide(harness.dependencies), + Effect.provideService(RelayClientTracer, Option.some(tracer)), + ); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + const firstAttempt = spans.find((span) => span.name === "relay.connection.attempt"); + expect(firstAttempt).toBeDefined(); + + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + const attempts = spans.filter((span) => span.name === "relay.connection.attempt"); + expect(attempts).toHaveLength(2); + expect(attempts[0]?.traceId).not.toBe(attempts[1]?.traceId); + expect(attempts[1]?.links.map((link) => link.span.spanId)).toContain(attempts[0]?.spanId); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not attempt a connection until it is desired", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + Effect.provide(harness.dependencies), + ); + + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("available"); + expect(yield* Ref.get(harness.prepareCount)).toBe(0); + }), + ); + + it.effect("does not let the initial connect signal cancel the first attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY).pipe( + Effect.provide(harness.dependencies), + ); + + yield* supervisor.connect; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + }), + ); + + it.effect("waits while offline and connects immediately when the network returns", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ networkStatus: "offline" }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + expect(yield* Ref.get(harness.prepareCount)).toBe(0); + + yield* harness.setNetworkStatus("online"); + const ready = yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(ready).toMatchObject({ + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + lastFailure: null, + }); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + }), + ); + + it.effect("retries forever with exponential backoff capped at sixteen seconds", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.fail(transient()), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + for (const [index, delay] of [1_000, 2_000, 4_000, 8_000, 16_000, 16_000].entries()) { + yield* TestClock.adjust(delay); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === index + 2, + ); + } + + expect(yield* Ref.get(harness.prepareCount)).toBe(7); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps the latest failure visible throughout the next connection attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient("Relay connection timed out.")) : Effect.never, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + yield* TestClock.adjust("1 second"); + + const retrying = yield* awaitState( + supervisor.state, + (state) => + state.phase === "connecting" && state.stage === "preparing" && state.attempt === 2, + ); + expect(retrying).toMatchObject({ + phase: "connecting", + stage: "preparing", + attempt: 2, + lastFailure: { + _tag: "ConnectionTransientError", + reason: "transport", + message: "Relay connection timed out.", + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("retries when a session never becomes ready", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + ready: () => Effect.never, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "synchronizing", + ); + yield* TestClock.adjust("14 seconds"); + expect((yield* SubscriptionRef.get(supervisor.state)).stage).toBe("synchronizing"); + + yield* TestClock.adjust("1 second"); + const retrying = yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + + expect(retrying).toMatchObject({ + phase: "backoff", + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("interrupts and releases a connection attempt when setup times out", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.never, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "preparing", + ); + yield* TestClock.adjust("15 seconds"); + const retrying = yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + expect(retrying).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("converts unexpected driver defects into retryable failures", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 + ? Effect.die(new Error("Native transport defect.")) + : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + const failed = yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(failed).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "transport", + message: "Test environment connection failed unexpectedly.", + }, + }); + + yield* TestClock.adjust("1 second"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("explicit retry interrupts the current backoff", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(transient()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + yield* supervisor.retryNow; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }), + ); + + it.effect("keeps blocked failures idle until an external signal requests another attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); + yield* TestClock.adjust("1 hour"); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + yield* supervisor.retryNow; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("releases a live session while offline and starts a new generation when online", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 1, + ); + yield* harness.setNetworkStatus("offline"); + yield* awaitState(supervisor.state, (state) => state.phase === "offline"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + + yield* harness.setNetworkStatus("online"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + }), + ); + + it.effect("retries a blocked connection when platform credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: (attempt) => + attempt === 1 ? Effect.fail(blocked()) : Effect.succeed(PREPARED_CONNECTION), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "blocked"); + yield* harness.wake("credentials-changed"); + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect(yield* Ref.get(harness.prepareCount)).toBe(2); + }), + ); + + it.effect("does not let platform wakeups reset an in-flight attempt", () => + Effect.gen(function* () { + const firstAttemptStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + prepare: () => + Deferred.succeed(firstAttemptStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(firstAttemptStarted); + yield* Effect.all( + [ + harness.wake("credentials-changed"), + harness.wake("application-active"), + harness.wake("credentials-changed"), + ], + { concurrency: "unbounded" }, + ); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + + yield* TestClock.adjust("15 seconds"); + const retrying = yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + expect(retrying).toMatchObject({ + lastFailure: { + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Test environment did not respond during connection setup.", + }, + }); + expect(yield* Ref.get(harness.prepareCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("treats an involuntary session close as transient and reconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(Option.isSome(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps escalating backoff when a newly opened session flaps", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.closeLatestSession(); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + yield* harness.closeLatestSession(); + const secondFailure = yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 2, + ); + + expect(secondFailure.retryAt).not.toBeNull(); + + yield* TestClock.adjust("1 second"); + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + + yield* TestClock.adjust("1 second"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 3, + ); + expect(yield* Ref.get(harness.sessionCount)).toBe(3); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps a healthy session when the application becomes active", () => + Effect.gen(function* () { + const probeCount = yield* Ref.make(0); + const probeCalled = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: () => + Ref.update(probeCount, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(probeCalled, undefined)), + ), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* Deferred.await(probeCalled); + + expect(yield* Ref.get(probeCount)).toBe(1); + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), + ); + + it.effect("reconnects when the foreground liveness probe fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => + attempt === 1 ? Effect.fail(transient("The live session is stale.")) : Effect.void, + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + yield* TestClock.adjust("1 second"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + + expect(yield* Ref.get(harness.sessionCount)).toBe(2); + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("times out a stalled foreground liveness probe and reconnects", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + probe: (attempt) => (attempt === 1 ? Effect.never : Effect.void), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* TestClock.adjust("15 seconds"); + yield* awaitState( + supervisor.state, + (state) => state.phase === "backoff" && state.lastFailure?.reason === "timeout", + ); + yield* TestClock.adjust("1 second"); + yield* eventuallyState( + supervisor.state, + (state) => state.phase === "connected" && state.generation === 2, + ); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("honors an explicit disconnect while a foreground probe is stalled", () => + Effect.gen(function* () { + const probeStarted = yield* Deferred.make(); + const harness = yield* makeHarness({ + probe: () => Deferred.succeed(probeStarted, undefined).pipe(Effect.andThen(Effect.never)), + }); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("application-active"); + yield* Deferred.await(probeStarted); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + }), + ); + + it.effect("does not churn a healthy session when credentials change", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* harness.wake("credentials-changed"); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.sessionCount)).toBe(1); + expect(yield* Ref.get(harness.releaseCount)).toBe(0); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connected"); + }), + ); + + it.effect("explicit disconnect releases the session and returns to available", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.prepared))).toBe(true); + }), + ); + + it.effect("does not lose an explicit disconnect among concurrent wakeup signals", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const supervisor = yield* makeEnvironmentSupervisor(TARGET_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + yield* Effect.all( + [ + supervisor.disconnect, + harness.wake("credentials-changed"), + harness.wake("application-active"), + harness.wake("credentials-changed"), + ], + { concurrency: "unbounded" }, + ); + yield* awaitState(supervisor.state, (state) => state.phase === "available"); + + expect(yield* Ref.get(harness.releaseCount)).toBe(1); + expect(Option.isNone(yield* SubscriptionRef.get(supervisor.session))).toBe(true); + }), + ); +}); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts new file mode 100644 index 00000000000..00835f7c869 --- /dev/null +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -0,0 +1,706 @@ +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as Tracer from "effect/Tracer"; + +import type { ConnectionCatalogEntry } from "./catalog.ts"; +import { Connectivity } from "./connectivity.ts"; +import { + ConnectionDriver, + type ConnectionDriverProgress, + type EnvironmentConnectionLease, +} from "./driver.ts"; +import { + type ConnectionAttemptError, + type ConnectionTarget, + ConnectionTransientError, + type NetworkStatus, + type PreparedConnection, + type SupervisorConnectionState, +} from "./model.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { type ConnectionWakeup, ConnectionWakeups } from "./wakeups.ts"; + +const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; +const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; +const CONNECTION_PROBE_TIMEOUT = "15 seconds"; +const BACKOFF_RESET_AFTER_MS = 30_000; + +interface SupervisorIntent { + readonly desired: boolean; + readonly network: NetworkStatus; +} + +type SupervisorSignal = + | { readonly _tag: "ConnectRequested" } + | { readonly _tag: "DisconnectRequested" } + | { readonly _tag: "RetryRequested" } + | { readonly _tag: "NetworkChanged"; readonly network: NetworkStatus } + | { readonly _tag: "Wakeup"; readonly reason: ConnectionWakeup }; + +interface PendingRetryTrace { + readonly previousAttempt: Tracer.Span; + readonly failureCount: number; + readonly delayMs: number; + readonly reason: ConnectionAttemptError["reason"]; +} + +interface TracedAttemptFailure { + readonly error: ConnectionAttemptError; + readonly attemptSpan: Option.Option; +} + +type AttemptOutcome = + | { + readonly _tag: "Interrupted"; + readonly established: boolean; + readonly stable: boolean; + } + | { + readonly _tag: "Failure"; + readonly established: boolean; + readonly stable: boolean; + readonly failure: TracedAttemptFailure; + }; + +type EstablishmentEvent = + | { + readonly _tag: "Completed"; + readonly exit: Exit.Exit< + { + readonly attemptSpan: Option.Option; + readonly lease: EnvironmentConnectionLease; + }, + TracedAttemptFailure + >; + } + | { readonly _tag: "Interrupted" } + | { readonly _tag: "TimedOut" }; + +function exitUnlessInterrupted( + effect: Effect.Effect, +): Effect.Effect, never, R> { + return Effect.matchCauseEffect(effect, { + onFailure: (cause) => + Cause.hasInterrupts(cause) ? Effect.interrupt : Effect.succeed(Exit.failCause(cause)), + onSuccess: (value) => Effect.succeed(Exit.succeed(value)), + }); +} + +export interface EnvironmentSupervisorOptions { + readonly initiallyDesired?: boolean; +} + +export interface EnvironmentSupervisorService { + readonly target: ConnectionTarget; + readonly state: SubscriptionRef.SubscriptionRef; + readonly session: SubscriptionRef.SubscriptionRef>; + readonly prepared: SubscriptionRef.SubscriptionRef>; + readonly connect: Effect.Effect; + readonly disconnect: Effect.Effect; + readonly retryNow: Effect.Effect; +} + +function retryDelayMs(failureCount: number): number { + return RETRY_DELAYS_MS[Math.min(failureCount, RETRY_DELAYS_MS.length - 1)] ?? 16_000; +} + +function annotateTarget(target: ConnectionTarget) { + return Effect.annotateCurrentSpan({ + "environment.id": target.environmentId, + "environment.label": target.label, + "environment.target.kind": target._tag, + }); +} + +function availableState(intent: SupervisorIntent, generation: number): SupervisorConnectionState { + return { + desired: false, + network: intent.network, + phase: "available", + stage: null, + attempt: 0, + generation, + lastFailure: null, + retryAt: null, + }; +} + +function offlineState( + intent: SupervisorIntent, + generation: number, + attempt: number, + lastFailure: ConnectionAttemptError | null, +): SupervisorConnectionState { + return { + desired: true, + network: intent.network, + phase: "offline", + stage: null, + attempt, + generation, + lastFailure, + retryAt: null, + }; +} + +function connectingState( + intent: SupervisorIntent, + generation: number, + attempt: number, + lastFailure: ConnectionAttemptError | null, + stage: SupervisorConnectionState["stage"] = "preparing", +): SupervisorConnectionState { + return { + desired: true, + network: intent.network, + phase: "connecting", + stage, + attempt, + generation, + lastFailure, + retryAt: null, + }; +} + +function failureFromExit( + target: ConnectionTarget, + exit: Exit.Exit, + established: boolean, + stable: boolean, +): AttemptOutcome { + if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) { + return { _tag: "Interrupted", established, stable }; + } + const typedFailure = exit.cause.reasons.find(Cause.isFailReason); + if (typedFailure) { + return { + _tag: "Failure", + established, + stable, + failure: typedFailure.error, + }; + } + return { + _tag: "Failure", + established, + stable, + failure: { + error: new ConnectionTransientError({ + reason: "transport", + message: `${target.label} connection failed unexpectedly.`, + }), + attemptSpan: Option.none(), + }, + }; +} + +export class EnvironmentSupervisor extends Context.Service< + EnvironmentSupervisor, + EnvironmentSupervisorService +>()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") { + static layer( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, + ): Layer.Layer< + EnvironmentSupervisor, + never, + Connectivity | ConnectionDriver | ConnectionWakeups + > { + return Layer.effect(EnvironmentSupervisor, makeEnvironmentSupervisor(entry, options)); + } +} + +export const makeEnvironmentSupervisor = Effect.fn("EnvironmentSupervisor.make")(function* ( + entry: ConnectionCatalogEntry, + options?: EnvironmentSupervisorOptions, +): Effect.fn.Return< + EnvironmentSupervisorService, + never, + Connectivity | ConnectionDriver | Scope.Scope | ConnectionWakeups +> { + const target = entry.target; + yield* annotateTarget(target); + + const connectivity = yield* Connectivity; + const driver = yield* ConnectionDriver; + const wakeups = yield* ConnectionWakeups; + const initialIntent: SupervisorIntent = { + desired: options?.initiallyDesired ?? false, + network: yield* connectivity.status, + }; + const intent = yield* Ref.make(initialIntent); + const signals = yield* Queue.unbounded(); + const state = yield* SubscriptionRef.make( + !initialIntent.desired + ? availableState(initialIntent, 0) + : initialIntent.network === "offline" + ? offlineState(initialIntent, 0, 0, null) + : connectingState(initialIntent, 0, 1, null), + ); + const session = yield* SubscriptionRef.make>(Option.none()); + const prepared = yield* SubscriptionRef.make>(Option.none()); + + const clearLease = Effect.all( + [SubscriptionRef.set(session, Option.none()), SubscriptionRef.set(prepared, Option.none())], + { discard: true }, + ); + + const setState = Effect.fn("EnvironmentSupervisor.setState")(function* ( + next: SupervisorConnectionState, + ) { + yield* SubscriptionRef.set(state, next); + }); + + const signal = Effect.fn("EnvironmentSupervisor.signal")(function* (next: SupervisorSignal) { + yield* Queue.offer(signals, next); + }); + + const reportProgress = Effect.fn("EnvironmentSupervisor.reportProgress")(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + progress: ConnectionDriverProgress, + ) { + if ("prepared" in progress) { + yield* SubscriptionRef.set(prepared, Option.some(progress.prepared)); + } + yield* setState( + connectingState(yield* Ref.get(intent), generation, attempt, lastFailure, progress.stage), + ); + }); + + const establishConnection = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + ) { + return yield* driver.connect(entry, (progress) => + reportProgress(attempt, generation, lastFailure, progress), + ); + }); + + const traceRelayEstablishment = ( + effect: Effect.Effect, + attempt: number, + generation: number, + pendingRetry: Option.Option, + ) => { + const traced = Effect.gen(function* () { + const attemptSpan = yield* Effect.currentSpan.pipe(Effect.orDie); + yield* annotateTarget(target); + yield* Effect.annotateCurrentSpan({ + "connection.attempt": attempt, + "connection.generation": generation, + "connection.retry.failure_count": Option.match(pendingRetry, { + onNone: () => 0, + onSome: (retry) => retry.failureCount, + }), + }); + const lease = yield* effect.pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.some(attemptSpan), + }), + ), + ); + return { attemptSpan: Option.some(attemptSpan), lease }; + }).pipe(Effect.withSpan("relay.connection.attempt", { root: true })); + + return Option.match(pendingRetry, { + onNone: () => traced, + onSome: (retry) => + traced.pipe( + Effect.linkSpans(retry.previousAttempt, { + "connection.retry.delay_ms": retry.delayMs, + "connection.retry.reason": retry.reason, + }), + ), + }).pipe(withRelayClientTracing); + }; + + const establishTracedConnection = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + pendingRetry: Option.Option, + ) { + if (target._tag === "RelayConnectionTarget") { + return yield* traceRelayEstablishment( + establishConnection(attempt, generation, lastFailure), + attempt, + generation, + pendingRetry, + ); + } + return yield* establishConnection(attempt, generation, lastFailure).pipe( + Effect.map((lease) => ({ + attemptSpan: Option.none(), + lease, + })), + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: Option.none(), + }), + ), + ); + }); + + const waitForEstablishmentInterrupt = Effect.fnUntraced(function* () { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "DisconnectRequested": + case "RetryRequested": + return; + case "NetworkChanged": + if (next.network === "offline") { + return; + } + break; + case "ConnectRequested": + case "Wakeup": + break; + } + } + }); + + const monitorConnectedLease = Effect.fnUntraced(function* (lease: EnvironmentConnectionLease) { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "DisconnectRequested": + case "RetryRequested": + return; + case "NetworkChanged": + if (next.network === "offline") { + return; + } + break; + case "Wakeup": + if (next.reason === "application-active") { + const probe = yield* lease.session.probe.pipe( + Effect.timeoutOrElse({ + duration: CONNECTION_PROBE_TIMEOUT, + orElse: () => + Effect.fail( + new ConnectionTransientError({ + reason: "timeout", + message: `${target.label} did not respond to a connection health check.`, + }), + ), + }), + Effect.forkChild, + ); + for (;;) { + const probeEvent = yield* Effect.raceFirst( + Fiber.await(probe).pipe( + Effect.map((exit) => ({ _tag: "ProbeCompleted" as const, exit })), + ), + Queue.take(signals).pipe( + Effect.map((signal) => ({ _tag: "Signal" as const, signal })), + ), + ); + if (probeEvent._tag === "ProbeCompleted") { + yield* probeEvent.exit; + break; + } + switch (probeEvent.signal._tag) { + case "DisconnectRequested": + case "RetryRequested": + yield* Fiber.interrupt(probe); + return; + case "NetworkChanged": + if (probeEvent.signal.network === "offline") { + yield* Fiber.interrupt(probe); + return; + } + break; + case "ConnectRequested": + case "Wakeup": + break; + } + } + } + break; + case "ConnectRequested": + break; + } + } + }); + + const runAttempt = Effect.fnUntraced(function* ( + attempt: number, + generation: number, + lastFailure: ConnectionAttemptError | null, + pendingRetry: Option.Option, + ) { + yield* SubscriptionRef.set(prepared, Option.none()); + const establishment = yield* Effect.raceAllFirst([ + exitUnlessInterrupted( + establishTracedConnection(attempt, generation, lastFailure, pendingRetry), + ).pipe( + Effect.map( + (exit): EstablishmentEvent => ({ + _tag: "Completed", + exit, + }), + ), + ), + waitForEstablishmentInterrupt().pipe(Effect.as({ _tag: "Interrupted" })), + Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( + Effect.as({ _tag: "TimedOut" }), + ), + ]); + + if (establishment._tag === "Interrupted") { + return { + _tag: "Interrupted", + established: false, + stable: false, + } satisfies AttemptOutcome; + } + if (establishment._tag === "TimedOut") { + return { + _tag: "Failure", + established: false, + stable: false, + failure: { + error: new ConnectionTransientError({ + reason: "timeout", + message: `${target.label} did not respond during connection setup.`, + }), + attemptSpan: Option.none(), + }, + } satisfies AttemptOutcome; + } + if (Exit.isFailure(establishment.exit)) { + const isUnexpectedDefect = + !Cause.hasInterruptsOnly(establishment.exit.cause) && + !establishment.exit.cause.reasons.some(Cause.isFailReason); + const outcome = failureFromExit(target, establishment.exit, false, false); + if (isUnexpectedDefect) { + yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( + Effect.annotateLogs({ + "environment.id": target.environmentId, + "environment.label": target.label, + cause: Cause.pretty(establishment.exit.cause), + }), + ); + } + return outcome; + } + + const active = establishment.exit.value; + const currentIntent = yield* Ref.get(intent); + if (!currentIntent.desired || currentIntent.network === "offline") { + return { + _tag: "Interrupted", + established: false, + stable: false, + } satisfies AttemptOutcome; + } + + const connectedAt = yield* Clock.currentTimeMillis; + yield* SubscriptionRef.set(prepared, Option.some(active.lease.prepared)); + yield* SubscriptionRef.set(session, Option.some(active.lease.session)); + yield* setState({ + desired: true, + network: currentIntent.network, + phase: "connected", + stage: null, + attempt, + generation, + lastFailure: null, + retryAt: null, + }); + + const connectedExit = yield* Effect.raceFirst( + active.lease.session.closed.pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + }), + ), + ), + monitorConnectedLease(active.lease).pipe( + Effect.mapError( + (error): TracedAttemptFailure => ({ + error, + attemptSpan: active.attemptSpan, + }), + ), + ), + ).pipe(exitUnlessInterrupted); + const connectedForMs = (yield* Clock.currentTimeMillis) - connectedAt; + return failureFromExit(target, connectedExit, true, connectedForMs >= BACKOFF_RESET_AFTER_MS); + }, Effect.ensuring(clearLease)); + + const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { + return yield* Effect.raceFirst( + Effect.sleep(delayMs), + Effect.gen(function* () { + for (;;) { + const next = yield* Queue.take(signals); + switch (next._tag) { + case "ConnectRequested": + case "DisconnectRequested": + case "RetryRequested": + case "NetworkChanged": + case "Wakeup": + return; + } + } + }), + ); + }); + + const waitForSignal = Queue.take(signals); + + const run = Effect.fnUntraced(function* () { + let failureCount = 0; + let generation = 0; + let latestFailure: ConnectionAttemptError | null = null; + let pendingRetry = Option.none(); + + for (;;) { + const currentIntent = yield* Ref.get(intent); + if (!currentIntent.desired) { + failureCount = 0; + latestFailure = null; + pendingRetry = Option.none(); + yield* clearLease; + yield* setState(availableState(currentIntent, generation)); + yield* waitForSignal; + continue; + } + if (currentIntent.network === "offline") { + yield* clearLease; + yield* setState(offlineState(currentIntent, generation, failureCount + 1, latestFailure)); + yield* waitForSignal; + continue; + } + + const attempt = failureCount + 1; + const nextGeneration = generation + 1; + const outcome: AttemptOutcome = yield* Effect.scoped( + runAttempt(attempt, nextGeneration, latestFailure, pendingRetry), + ); + if (outcome.established) { + generation = nextGeneration; + if (outcome.stable) { + failureCount = 0; + latestFailure = null; + pendingRetry = Option.none(); + } + } + if (outcome._tag === "Interrupted") { + continue; + } + + const attemptSpan: Option.Option = outcome.failure.attemptSpan; + const error: ConnectionAttemptError = outcome.failure.error; + latestFailure = error; + if (error._tag === "ConnectionBlockedError") { + const blockedIntent = yield* Ref.get(intent); + yield* setState({ + desired: blockedIntent.desired, + network: blockedIntent.network, + phase: "blocked", + stage: null, + attempt, + generation, + lastFailure: error, + retryAt: null, + }); + yield* waitForSignal; + continue; + } + + failureCount += 1; + const delayMs = retryDelayMs(failureCount - 1); + pendingRetry = Option.map(attemptSpan, (previousAttempt) => ({ + previousAttempt, + failureCount, + delayMs, + reason: error.reason, + })); + const failedIntent = yield* Ref.get(intent); + yield* setState({ + desired: failedIntent.desired, + network: failedIntent.network, + phase: "backoff", + stage: null, + attempt, + generation, + lastFailure: error, + retryAt: (yield* Clock.currentTimeMillis) + delayMs, + }); + yield* waitForRetrySignal(delayMs); + } + }); + + yield* connectivity.changes.pipe( + Stream.runForEach((network) => + Ref.modify(intent, (current) => + current.network === network ? [false, current] : ([true, { ...current, network }] as const), + ).pipe( + Effect.flatMap((changed) => + changed ? signal({ _tag: "NetworkChanged", network }) : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* wakeups.changes.pipe( + Stream.runForEach((reason) => signal({ _tag: "Wakeup", reason })), + Effect.forkScoped, + ); + yield* run().pipe(Effect.forkScoped); + + const connect = Ref.update(intent, (current) => ({ + ...current, + desired: true, + })).pipe( + Effect.andThen(signal({ _tag: "ConnectRequested" })), + Effect.withSpan("EnvironmentSupervisor.connect"), + ); + + const disconnect = Ref.update(intent, (current) => ({ + ...current, + desired: false, + })).pipe( + Effect.andThen(signal({ _tag: "DisconnectRequested" })), + Effect.withSpan("EnvironmentSupervisor.disconnect"), + ); + + const retryNow = signal({ _tag: "RetryRequested" }).pipe( + Effect.withSpan("EnvironmentSupervisor.retryNow"), + ); + + yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease))); + + return EnvironmentSupervisor.of({ + target, + state, + session, + prepared, + connect, + disconnect, + retryNow, + }); +}); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts new file mode 100644 index 00000000000..93449077838 --- /dev/null +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -0,0 +1,11 @@ +import * as Context from "effect/Context"; +import type * as Stream from "effect/Stream"; + +export type ConnectionWakeup = "application-active" | "credentials-changed"; + +export class ConnectionWakeups extends Context.Service< + ConnectionWakeups, + { + readonly changes: Stream.Stream; + } +>()("@t3tools/client-runtime/connection/wakeups/ConnectionWakeups") {} diff --git a/packages/client-runtime/src/environment/descriptor.ts b/packages/client-runtime/src/environment/descriptor.ts new file mode 100644 index 00000000000..d49a0d9a890 --- /dev/null +++ b/packages/client-runtime/src/environment/descriptor.ts @@ -0,0 +1,17 @@ +import * as Effect from "effect/Effect"; + +import { environmentEndpointUrl } from "./endpoint.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; + +const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; + +export const fetchRemoteEnvironmentDescriptor = Effect.fn( + "clientRuntime.environment.fetchRemoteEnvironmentDescriptor", +)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { + const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + return yield* executeEnvironmentHttpRequest( + environmentEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), + input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, + client.metadata.descriptor(), + ); +}); diff --git a/packages/client-runtime/src/advertisedEndpoint.test.ts b/packages/client-runtime/src/environment/endpoint.test.ts similarity index 98% rename from packages/client-runtime/src/advertisedEndpoint.test.ts rename to packages/client-runtime/src/environment/endpoint.test.ts index b55c6d817dd..d26201dc4f7 100644 --- a/packages/client-runtime/src/advertisedEndpoint.test.ts +++ b/packages/client-runtime/src/environment/endpoint.test.ts @@ -5,7 +5,7 @@ import { createAdvertisedEndpoint, deriveWsBaseUrl, normalizeHttpBaseUrl, -} from "./advertisedEndpoint.ts"; +} from "./endpoint.ts"; const coreProvider = { id: "desktop-core", diff --git a/packages/client-runtime/src/environment/endpoint.ts b/packages/client-runtime/src/environment/endpoint.ts new file mode 100644 index 00000000000..4178259361e --- /dev/null +++ b/packages/client-runtime/src/environment/endpoint.ts @@ -0,0 +1,9 @@ +export * from "@t3tools/shared/advertisedEndpoint"; + +export const environmentEndpointUrl = (httpBaseUrl: string, pathname: string): string => { + const url = new URL(httpBaseUrl); + url.pathname = pathname; + url.search = ""; + url.hash = ""; + return url.toString(); +}; diff --git a/packages/client-runtime/src/environment/index.ts b/packages/client-runtime/src/environment/index.ts new file mode 100644 index 00000000000..03c6bf6e491 --- /dev/null +++ b/packages/client-runtime/src/environment/index.ts @@ -0,0 +1,4 @@ +export * from "./descriptor.ts"; +export * from "./endpoint.ts"; +export * from "./knownEnvironment.ts"; +export * from "./scoped.ts"; diff --git a/packages/client-runtime/src/knownEnvironment.test.ts b/packages/client-runtime/src/environment/knownEnvironment.test.ts similarity index 72% rename from packages/client-runtime/src/knownEnvironment.test.ts rename to packages/client-runtime/src/environment/knownEnvironment.test.ts index cb96ab2417e..66bbb1df7e9 100644 --- a/packages/client-runtime/src/knownEnvironment.test.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.test.ts @@ -1,7 +1,7 @@ import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { createKnownEnvironment, getKnownEnvironmentHttpBaseUrl } from "./knownEnvironment.ts"; +import { createKnownEnvironment } from "./knownEnvironment.ts"; import { parseScopedProjectKey, parseScopedThreadKey, @@ -32,32 +32,6 @@ describe("known environment bootstrap helpers", () => { }, }); }); - - it("returns the explicit fetchable http origin", () => { - expect( - getKnownEnvironmentHttpBaseUrl( - createKnownEnvironment({ - label: "Local environment", - target: { - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - }, - }), - ), - ).toBe("http://localhost:3773"); - - expect( - getKnownEnvironmentHttpBaseUrl( - createKnownEnvironment({ - label: "Remote environment", - target: { - httpBaseUrl: "https://remote.example.com/api", - wsBaseUrl: "wss://remote.example.com/api", - }, - }), - ), - ).toBe("https://remote.example.com/api"); - }); }); describe("scoped refs", () => { diff --git a/packages/client-runtime/src/knownEnvironment.ts b/packages/client-runtime/src/environment/knownEnvironment.ts similarity index 77% rename from packages/client-runtime/src/knownEnvironment.ts rename to packages/client-runtime/src/environment/knownEnvironment.ts index 495a6ddc9a7..42d3c8fbeb1 100644 --- a/packages/client-runtime/src/knownEnvironment.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.ts @@ -29,18 +29,6 @@ export function createKnownEnvironment(input: { }; } -export function getKnownEnvironmentWsBaseUrl( - environment: KnownEnvironment | null | undefined, -): string | null { - return environment?.target.wsBaseUrl ?? null; -} - -export function getKnownEnvironmentHttpBaseUrl( - environment: KnownEnvironment | null | undefined, -): string | null { - return environment?.target.httpBaseUrl ?? null; -} - export function attachEnvironmentDescriptor( environment: KnownEnvironment, descriptor: ExecutionEnvironmentDescriptor, diff --git a/packages/client-runtime/src/scoped.ts b/packages/client-runtime/src/environment/scoped.ts similarity index 100% rename from packages/client-runtime/src/scoped.ts rename to packages/client-runtime/src/environment/scoped.ts diff --git a/packages/client-runtime/src/environmentConnection.ts b/packages/client-runtime/src/environmentConnection.ts deleted file mode 100644 index 636b1808595..00000000000 --- a/packages/client-runtime/src/environmentConnection.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, - ServerConfig, - ServerLifecycleWelcomePayload, - TerminalEvent, -} from "@t3tools/contracts"; - -import type { KnownEnvironment } from "./knownEnvironment.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface EnvironmentConnection { - readonly kind: "primary" | "saved"; - readonly environmentId: EnvironmentId; - readonly knownEnvironment: KnownEnvironment; - readonly client: WsRpcClient; - readonly ensureBootstrapped: () => Promise; - readonly reconnect: () => Promise; - readonly dispose: () => Promise; -} - -interface OrchestrationHandlers { - readonly applyShellEvent: ( - event: OrchestrationShellStreamEvent, - environmentId: EnvironmentId, - ) => void; - readonly syncShellSnapshot: ( - snapshot: OrchestrationShellSnapshot, - environmentId: EnvironmentId, - ) => void; - readonly applyTerminalEvent?: (event: TerminalEvent, environmentId: EnvironmentId) => void; -} - -export interface EnvironmentConnectionInput extends OrchestrationHandlers { - readonly kind: "primary" | "saved"; - readonly knownEnvironment: KnownEnvironment; - readonly client: WsRpcClient; - readonly refreshMetadata?: () => Promise; - readonly onConfigSnapshot?: (config: ServerConfig) => void; - readonly onWelcome?: (payload: ServerLifecycleWelcomePayload) => void; - readonly onShellResubscribe?: (environmentId: EnvironmentId) => void; -} - -export interface EnvironmentConnectionAttempt { - readonly environmentId: EnvironmentId; - readonly isCurrent: () => boolean; -} - -export class EnvironmentConnectionAttemptCancelledError extends Error { - constructor(environmentId: EnvironmentId) { - super(`Environment connection attempt ${environmentId} was cancelled.`); - this.name = "EnvironmentConnectionAttemptCancelledError"; - } -} - -export function createEnvironmentConnectionAttemptRegistry() { - const attempts = new Map(); - - return { - begin: (environmentId: EnvironmentId): EnvironmentConnectionAttempt => { - const id = Symbol(environmentId); - attempts.set(environmentId, id); - return { - environmentId, - isCurrent: () => attempts.get(environmentId) === id, - }; - }, - cancel: (environmentId: EnvironmentId): void => { - attempts.delete(environmentId); - }, - clear: (): void => { - attempts.clear(); - }, - }; -} - -export class EnvironmentConnectionDisposedError extends Error { - constructor(environmentId: EnvironmentId) { - super(`Environment connection ${environmentId} was disposed before it finished bootstrapping.`); - this.name = "EnvironmentConnectionDisposedError"; - } -} - -function createBootstrapGate() { - let resolve: (() => void) | null = null; - let reject: ((error: unknown) => void) | null = null; - const makePromise = () => { - const nextPromise = new Promise((nextResolve, nextReject) => { - resolve = nextResolve; - reject = nextReject; - }); - void nextPromise.catch(() => undefined); - return nextPromise; - }; - let promise = makePromise(); - - return { - wait: () => promise, - resolve: () => { - resolve?.(); - resolve = null; - reject = null; - }, - reject: (error: unknown) => { - reject?.(error); - resolve = null; - reject = null; - }, - reset: () => { - promise = makePromise(); - }, - }; -} - -export function createEnvironmentConnection( - input: EnvironmentConnectionInput, -): EnvironmentConnection { - const environmentId = input.knownEnvironment.environmentId; - - if (!environmentId) { - throw new Error( - `Known environment ${input.knownEnvironment.label} is missing its environmentId.`, - ); - } - - let disposed = false; - const bootstrapGate = createBootstrapGate(); - const shouldObserveLifecycle = input.kind === "saved" || input.onWelcome !== undefined; - const shouldObserveConfig = input.kind === "saved" || input.onConfigSnapshot !== undefined; - - const observeEnvironmentIdentity = (nextEnvironmentId: EnvironmentId, source: string) => { - if (environmentId !== nextEnvironmentId) { - throw new Error( - `Environment connection ${environmentId} changed identity to ${nextEnvironmentId} via ${source}.`, - ); - } - }; - - const unsubLifecycle = shouldObserveLifecycle - ? input.client.server.subscribeLifecycle((event) => { - if (disposed || event.type !== "welcome") { - return; - } - - observeEnvironmentIdentity( - event.payload.environment.environmentId, - "server lifecycle welcome", - ); - input.onWelcome?.(event.payload); - }) - : () => undefined; - - const unsubConfig = shouldObserveConfig - ? input.client.server.subscribeConfig((event) => { - if (disposed || event.type !== "snapshot") { - return; - } - - observeEnvironmentIdentity( - event.config.environment.environmentId, - "server config snapshot", - ); - input.onConfigSnapshot?.(event.config); - }) - : () => undefined; - - const unsubShell = input.client.orchestration.subscribeShell( - (item) => { - if (disposed) { - return; - } - - if (item.kind === "snapshot") { - input.syncShellSnapshot(item.snapshot, environmentId); - bootstrapGate.resolve(); - return; - } - - input.applyShellEvent(item, environmentId); - }, - { - onResubscribe: () => { - if (disposed) { - return; - } - - bootstrapGate.reset(); - input.onShellResubscribe?.(environmentId); - }, - }, - ); - - const unsubTerminalEvent = input.applyTerminalEvent - ? input.client.terminal.onEvent((event) => { - if (!disposed) { - input.applyTerminalEvent?.(event, environmentId); - } - }) - : () => undefined; - - const cleanup = () => { - if (disposed) { - return; - } - - disposed = true; - bootstrapGate.reject(new EnvironmentConnectionDisposedError(environmentId)); - unsubShell(); - unsubTerminalEvent(); - unsubLifecycle(); - unsubConfig(); - }; - - return { - kind: input.kind, - environmentId, - knownEnvironment: input.knownEnvironment, - client: input.client, - ensureBootstrapped: () => - disposed - ? Promise.reject(new EnvironmentConnectionDisposedError(environmentId)) - : bootstrapGate.wait(), - reconnect: async () => { - if (disposed) { - throw new EnvironmentConnectionDisposedError(environmentId); - } - - bootstrapGate.reset(); - try { - await input.client.reconnect(); - await input.refreshMetadata?.(); - await bootstrapGate.wait(); - } catch (error) { - bootstrapGate.reject(error); - throw error; - } - }, - dispose: async () => { - cleanup(); - await input.client.dispose(); - }, - }; -} diff --git a/packages/client-runtime/src/environmentRuntimeState.test.ts b/packages/client-runtime/src/environmentRuntimeState.test.ts deleted file mode 100644 index 79b245335a9..00000000000 --- a/packages/client-runtime/src/environmentRuntimeState.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { EnvironmentId } from "@t3tools/contracts"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { createEnvironmentRuntimeManager } from "./environmentRuntimeState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; - -describe("createEnvironmentRuntimeManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("stores state per environment", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.setState(TARGET, { - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - }); - - it("patches the current state", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.patch(TARGET, (current) => ({ - ...current, - connectionState: "disconnected", - connectionError: "Socket closed.", - })); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "disconnected", - connectionError: "Socket closed.", - serverConfig: null, - }); - }); - - it("invalidates a single environment", () => { - const manager = createEnvironmentRuntimeManager({ - getRegistry: () => atomRegistry, - }); - - manager.setState(TARGET, { - connectionState: "ready", - connectionError: null, - serverConfig: null, - }); - manager.invalidate(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - connectionState: "idle", - connectionError: null, - serverConfig: null, - }); - }); -}); diff --git a/packages/client-runtime/src/environmentRuntimeState.ts b/packages/client-runtime/src/environmentRuntimeState.ts deleted file mode 100644 index e25979c8cfd..00000000000 --- a/packages/client-runtime/src/environmentRuntimeState.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { EnvironmentId, ServerConfig as T3ServerConfig } from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export type EnvironmentConnectionState = - | "idle" - | "connecting" - | "ready" - | "reconnecting" - | "disconnected"; - -export interface EnvironmentRuntimeState { - readonly connectionState: EnvironmentConnectionState; - readonly connectionError: string | null; - readonly serverConfig: T3ServerConfig | null; -} - -export interface EnvironmentRuntimeTarget { - readonly environmentId: EnvironmentId | null; -} - -export const EMPTY_ENVIRONMENT_RUNTIME_STATE = Object.freeze({ - connectionState: "idle", - connectionError: null, - serverConfig: null, -}); - -const knownEnvironmentRuntimeKeys = new Set(); - -export const environmentRuntimeStateAtom = Atom.family((key: string) => { - knownEnvironmentRuntimeKeys.add(key); - return Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`environment-runtime:${key}`), - ); -}); - -export const EMPTY_ENVIRONMENT_RUNTIME_ATOM = Atom.make(EMPTY_ENVIRONMENT_RUNTIME_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("environment-runtime:null"), -); - -export function getEnvironmentRuntimeTargetKey(target: EnvironmentRuntimeTarget): string | null { - return target.environmentId; -} - -export interface EnvironmentRuntimeManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; -} - -export function createEnvironmentRuntimeManager(config: EnvironmentRuntimeManagerConfig) { - function getSnapshot(target: EnvironmentRuntimeTarget): EnvironmentRuntimeState { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return EMPTY_ENVIRONMENT_RUNTIME_STATE; - } - - return config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); - } - - function setState(target: EnvironmentRuntimeTarget, nextState: EnvironmentRuntimeState): void { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return; - } - - config.getRegistry().set(environmentRuntimeStateAtom(targetKey), nextState); - } - - function patch( - target: EnvironmentRuntimeTarget, - updater: (current: EnvironmentRuntimeState) => EnvironmentRuntimeState, - ): void { - const targetKey = getEnvironmentRuntimeTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(environmentRuntimeStateAtom(targetKey)); - config.getRegistry().set(environmentRuntimeStateAtom(targetKey), updater(current)); - } - - function invalidate(target?: EnvironmentRuntimeTarget): void { - if (target) { - setState(target, EMPTY_ENVIRONMENT_RUNTIME_STATE); - return; - } - - for (const key of knownEnvironmentRuntimeKeys) { - config.getRegistry().set(environmentRuntimeStateAtom(key), EMPTY_ENVIRONMENT_RUNTIME_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - getSnapshot, - setState, - patch, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/errors/errorTrace.test.ts b/packages/client-runtime/src/errors/errorTrace.test.ts new file mode 100644 index 00000000000..075049bd55e --- /dev/null +++ b/packages/client-runtime/src/errors/errorTrace.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { findErrorTraceId } from "./errorTrace.ts"; + +describe("findErrorTraceId", () => { + it("finds trace metadata through wrapped typed errors", () => { + expect( + findErrorTraceId({ + cause: { + cause: { + _tag: "RelayInternalError", + traceId: "trace-relay", + }, + }, + }), + ).toBe("trace-relay"); + }); + + it("terminates for cyclic causes", () => { + const error: { cause?: unknown } = {}; + error.cause = error; + + expect(findErrorTraceId(error)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/errors/errorTrace.ts b/packages/client-runtime/src/errors/errorTrace.ts new file mode 100644 index 00000000000..ec1b2a6b2cd --- /dev/null +++ b/packages/client-runtime/src/errors/errorTrace.ts @@ -0,0 +1,18 @@ +export function findErrorTraceId(error: unknown): string | null { + const seen = new Set(); + let current: unknown = error; + + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const record = current as { + readonly cause?: unknown; + readonly traceId?: unknown; + }; + if (typeof record.traceId === "string" && record.traceId.trim().length > 0) { + return record.traceId; + } + current = record.cause; + } + + return null; +} diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts new file mode 100644 index 00000000000..a29060e6758 --- /dev/null +++ b/packages/client-runtime/src/errors/index.ts @@ -0,0 +1,2 @@ +export * from "./errorTrace.ts"; +export * from "./transport.ts"; diff --git a/packages/client-runtime/src/transportError.test.ts b/packages/client-runtime/src/errors/transport.test.ts similarity index 80% rename from packages/client-runtime/src/transportError.test.ts rename to packages/client-runtime/src/errors/transport.test.ts index 7c0417a91ef..692b3af4a51 100644 --- a/packages/client-runtime/src/transportError.test.ts +++ b/packages/client-runtime/src/errors/transport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transportError.ts"; +import { isTransportConnectionErrorMessage, sanitizeThreadErrorMessage } from "./transport.ts"; describe("isTransportConnectionErrorMessage", () => { it("returns true for SocketCloseError", () => { @@ -19,6 +19,17 @@ describe("isTransportConnectionErrorMessage", () => { ).toBe(true); }); + it("recognizes connection errors emitted by the Effect RPC session", () => { + expect(isTransportConnectionErrorMessage("Test environment disconnected.")).toBe(true); + expect( + isTransportConnectionErrorMessage( + "Test environment could not establish a WebSocket connection.", + ), + ).toBe(true); + expect(isTransportConnectionErrorMessage("Test environment is not connected.")).toBe(true); + expect(isTransportConnectionErrorMessage("ClientProtocolError: socket closed")).toBe(true); + }); + it("returns true for the T3 server WebSocket message", () => { expect(isTransportConnectionErrorMessage("Unable to connect to the T3 server WebSocket.")).toBe( true, diff --git a/packages/client-runtime/src/transportError.ts b/packages/client-runtime/src/errors/transport.ts similarity index 81% rename from packages/client-runtime/src/transportError.ts rename to packages/client-runtime/src/errors/transport.ts index fe0ad9f98d6..e21c5d4ecf5 100644 --- a/packages/client-runtime/src/transportError.ts +++ b/packages/client-runtime/src/errors/transport.ts @@ -3,11 +3,16 @@ const TRANSPORT_ERROR_PATTERNS = [ /\bSocketOpenError\b/i, /\bSocket is not connected\b/i, /Unable to connect to the T3 server WebSocket\./i, + /\bis not connected\.$/i, + /\bdisconnected\.$/i, + /\bcould not establish a WebSocket connection\.$/i, + /\bClientProtocolError\b/i, + /\bRpcClientError\b/i, /\bping timeout\b/i, ] as const; /** - * Test whether an error message originates from a transport-level connection + * Check whether an error message originates from a transport-level connection * failure (socket close, socket open, ping timeout, etc.) rather than a * business-logic error. */ diff --git a/packages/client-runtime/src/filesystemBrowseState.test.ts b/packages/client-runtime/src/filesystemBrowseState.test.ts deleted file mode 100644 index c06ac6806ae..00000000000 --- a/packages/client-runtime/src/filesystemBrowseState.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { assert, beforeEach, it } from "vite-plus/test"; -import type { FilesystemBrowseResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - EMPTY_FILESYSTEM_BROWSE_STATE, - createFilesystemBrowseManager, -} from "./filesystemBrowseState.ts"; - -const ROOT_RESULT: FilesystemBrowseResult = { - parentPath: "/Users/julius", - entries: [ - { - name: "code", - fullPath: "/Users/julius/code", - }, - ], -}; - -let registry = AtomRegistry.make(); - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function unresolvedBrowse() { - throw new Error("Browse resolver was not initialized."); -} - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -it("stores browsed folder data in an atom snapshot", async () => { - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: async () => ROOT_RESULT, - }), - }); - - assert.deepStrictEqual( - manager.getSnapshot({ key: null, input: null }), - EMPTY_FILESYSTEM_BROWSE_STATE, - ); - - const target = { key: "env-1", input: { partialPath: "~" } }; - const result = await manager.refresh(target); - - assert.strictEqual(result, ROOT_RESULT); - assert.deepStrictEqual(manager.getSnapshot(target), { - data: ROOT_RESULT, - error: null, - isPending: false, - }); -}); - -it("deduplicates in-flight browse refreshes by target input", async () => { - let resolveBrowse: (result: FilesystemBrowseResult) => void = unresolvedBrowse; - let calls = 0; - const target = { key: "env-1", input: { partialPath: "~" } }; - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: () => { - calls += 1; - return new Promise((resolve) => { - resolveBrowse = resolve; - }); - }, - }), - }); - - const first = manager.refresh(target); - const second = manager.refresh(target); - - assert.strictEqual(first, second); - assert.strictEqual(calls, 1); - assert.deepStrictEqual(manager.getSnapshot(target), { - data: null, - error: null, - isPending: true, - }); - - resolveBrowse(ROOT_RESULT); - await first; - - assert.deepStrictEqual(manager.getSnapshot(target), { - data: ROOT_RESULT, - error: null, - isPending: false, - }); -}); - -it("keeps fresh watched browse results on remount", async () => { - let browseCalls = 0; - const target = { key: "env-1", input: { partialPath: "~" } }; - const manager = createFilesystemBrowseManager({ - getRegistry: () => registry, - getClient: () => ({ - browse: async () => { - browseCalls += 1; - return ROOT_RESULT; - }, - }), - staleTimeMs: 60_000, - }); - - const firstUnwatch = manager.watch(target); - await flushAsyncWork(); - firstUnwatch(); - - const secondUnwatch = manager.watch(target); - await flushAsyncWork(); - secondUnwatch(); - - assert.strictEqual(browseCalls, 1); -}); diff --git a/packages/client-runtime/src/filesystemBrowseState.ts b/packages/client-runtime/src/filesystemBrowseState.ts deleted file mode 100644 index b1c72966d4d..00000000000 --- a/packages/client-runtime/src/filesystemBrowseState.ts +++ /dev/null @@ -1,339 +0,0 @@ -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface FilesystemBrowseState { - readonly data: FilesystemBrowseResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface FilesystemBrowseTarget { - readonly key: TKey | null; - readonly input: FilesystemBrowseInput | null; -} - -export interface FilesystemBrowseClient { - readonly browse: (input: FilesystemBrowseInput) => Promise; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -export const EMPTY_FILESYSTEM_BROWSE_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_FILESYSTEM_BROWSE_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownFilesystemBrowseKeys = new Set(); - -export const filesystemBrowseStateAtom = Atom.family((targetKey: string) => { - knownFilesystemBrowseKeys.add(targetKey); - return Atom.make(INITIAL_FILESYSTEM_BROWSE_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`filesystem-browse:${targetKey}`), - ); -}); - -export const EMPTY_FILESYSTEM_BROWSE_ATOM = Atom.make(EMPTY_FILESYSTEM_BROWSE_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("filesystem-browse:null"), -); - -const NOOP: () => void = () => undefined; -const DEFAULT_STALE_TIME_MS = 30_000; -const DEFAULT_IDLE_TTL_MS = 5 * 60_000; - -export function getFilesystemBrowseTargetKey( - target: FilesystemBrowseTarget, -): string | null { - const key = target.key; - const input = target.input; - if (!key || !input || input.partialPath.length === 0) { - return null; - } - - return JSON.stringify([key, input.cwd ?? null, input.partialPath]); -} - -export interface FilesystemBrowseManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (key: TKey) => FilesystemBrowseClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -} - -export function createFilesystemBrowseManager( - config: FilesystemBrowseManagerConfig, -) { - const refreshInFlight = new Map< - string, - { - readonly client: FilesystemBrowseClient; - readonly promise: Promise; - } - >(); - const refreshVersions = new Map(); - const watched = new Map(); - const refreshTargets = new Map>(); - const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; - const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? refresh(target) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: staleTimeMs, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtlMs), - Atom.withLabel(`filesystem-browse:watched-refresh:${targetKey}`), - ), - ); - - function getRefreshVersion(targetKey: string): number { - return refreshVersions.get(targetKey) ?? 0; - } - - function bumpRefreshVersion(targetKey: string): void { - refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); - } - - function setState(targetKey: string, nextState: FilesystemBrowseState): void { - config.getRegistry().set(filesystemBrowseStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - const next: FilesystemBrowseState = - current.data === null - ? INITIAL_FILESYSTEM_BROWSE_STATE - : { - data: current.data, - error: null, - isPending: true, - }; - - if ( - current.data === next.data && - current.error === next.error && - current.isPending === next.isPending - ) { - return; - } - - setState(targetKey, next); - } - - function setData(targetKey: string, data: FilesystemBrowseResult): void { - setState(targetKey, { - data, - error: null, - isPending: false, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: error instanceof Error ? error.message : "Failed to browse folder.", - isPending: false, - }); - } - - function refresh( - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): Promise { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null || target.key === null || target.input === null) { - return Promise.resolve(null); - } - refreshTargets.set(targetKey, target); - - const resolvedClient = client ?? config.getClient(target.key); - if (!resolvedClient) { - setError(targetKey, new Error("Filesystem browser client is unavailable.")); - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) { - if (!client || existing.client === resolvedClient) { - return existing.promise; - } - return existing.promise.then(() => refresh(target, resolvedClient)); - } - - markPending(targetKey); - const refreshVersion = getRefreshVersion(targetKey); - const promise = resolvedClient.browse(target.input).then( - (result) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setData(targetKey, result); - } - return result; - }, - (error: unknown) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setError(targetKey, error); - } - return getSnapshot(target).data; - }, - ); - - let tracked: Promise; - tracked = promise.finally(() => { - if (refreshInFlight.get(targetKey)?.promise === tracked) { - refreshInFlight.delete(targetKey); - } - }); - refreshInFlight.set(targetKey, { - client: resolvedClient, - promise: tracked, - }); - return tracked; - } - - function invalidate(target?: FilesystemBrowseTarget): void { - if (!target) { - reset(); - return; - } - - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null) { - return; - } - - bumpRefreshVersion(targetKey); - refreshInFlight.delete(targetKey); - setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); - } - - function getSnapshot(target: FilesystemBrowseTarget): FilesystemBrowseState { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null) { - return EMPTY_FILESYSTEM_BROWSE_STATE; - } - - return config.getRegistry().get(filesystemBrowseStateAtom(targetKey)); - } - - function watch( - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): () => void { - const targetKey = getFilesystemBrowseTargetKey(target); - if (targetKey === null || target.key === null) { - return NOOP; - } - refreshTargets.set(targetKey, target); - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - void refresh(target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: FilesystemBrowseClient | null = null; - - const sync = () => { - const resolved = config.getClient(target.key!); - if (!resolved) { - currentClient = null; - markPending(targetKey); - return; - } - - if (currentClient === resolved) { - return; - } - - const isClientReplacement = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); - }; - - const unsubChanges = config.subscribeClientChanges(sync); - sync(); - teardown = unsubChanges; - } else { - if (!config.getClient(target.key)) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function refreshWatchedTarget( - targetKey: string, - target: FilesystemBrowseTarget, - client?: FilesystemBrowseClient, - ): void { - refreshTargets.set(targetKey, target); - const registry = config.getRegistry(); - void registry.get(watchedRefreshAtom(targetKey)); - if (client) { - void refresh(target, client); - } - } - - function reset(): void { - refreshInFlight.clear(); - watched.clear(); - refreshTargets.clear(); - for (const targetKey of knownFilesystemBrowseKeys) { - bumpRefreshVersion(targetKey); - setState(targetKey, INITIAL_FILESYSTEM_BROWSE_STATE); - } - } - - return { - refresh, - invalidate, - getSnapshot, - watch, - reset, - }; -} diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts deleted file mode 100644 index ac32e794fe4..00000000000 --- a/packages/client-runtime/src/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export * from "./advertisedEndpoint.ts"; -export * from "./knownEnvironment.ts"; -export * from "./reconnectBackoff.ts"; -export * from "./scoped.ts"; -export * from "./projectPaths.ts"; -export * from "./addProject.ts"; -export * from "./filesystemBrowseState.ts"; -export * from "./sourceControlDiscoveryState.ts"; -export * from "./environmentRuntimeState.ts"; -export * from "./shellTypes.ts"; -export * from "./shellSnapshotReducer.ts"; -export * from "./shellSnapshotState.ts"; -export * from "./threadDetailReducer.ts"; -export * from "./threadDetailState.ts"; -export * from "./gitActions.ts"; -export * from "./vcsActionState.ts"; -export * from "./vcsRefState.ts"; -export * from "./vcsStatusState.ts"; -export * from "./terminalSessionState.ts"; -export * from "./transportError.ts"; -export * from "./wsRpcProtocol.ts"; -export * from "./wsTransport.ts"; -export * from "./wsRpcClient.ts"; -export * from "./environmentConnection.ts"; -export * from "./composerPathSearchState.ts"; -export * from "./archivedThreadsState.ts"; -export * from "./checkpointDiffState.ts"; -export * from "./remote.ts"; -export * from "./managedRelay.ts"; -export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/managedRelay.test.ts b/packages/client-runtime/src/managedRelay.test.ts deleted file mode 100644 index e340f12f620..00000000000 --- a/packages/client-runtime/src/managedRelay.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; -import { describe, expect, it } from "@effect/vitest"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as Layer from "effect/Layer"; -import * as TestClock from "effect/testing/TestClock"; - -import { - MANAGED_RELAY_REQUEST_TIMEOUT_MS, - ManagedRelayClient, - ManagedRelayDpopSigner, - managedRelayClientLayer, - type ManagedRelayDpopProofInput, -} from "./managedRelay.ts"; -import { remoteHttpClientLayer } from "./remote.ts"; - -function managedRelayTestLayer( - fetchFn: typeof globalThis.fetch, - relayUrl = "https://relay.example.test", -) { - const httpClientLayer = remoteHttpClientLayer(fetchFn); - const signerLayer = Layer.succeed( - ManagedRelayDpopSigner, - ManagedRelayDpopSigner.of({ - thumbprint: Effect.succeed("client-thumbprint"), - createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), - }), - ); - return managedRelayClientLayer({ - relayUrl, - clientId: "t3-mobile", - }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); -} - -describe("ManagedRelayClient", () => { - it.effect("rejects unsafe relay URLs before sending credentials", () => { - let requestCount = 0; - const fetchFn = (() => { - requestCount += 1; - return Promise.resolve(Response.json({})); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const error = yield* relayClient - .listEnvironments({ clerkToken: "clerk-token" }) - .pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay URL must be a secure absolute HTTPS origin.", - }); - expect(requestCount).toBe(0); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); - }); - - it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { - let tokenExchangeCount = 0; - const fetchFn = ((input) => { - const url = String(input); - if (url.endsWith("/v1/client/dpop-token")) { - tokenExchangeCount += 1; - return Promise.resolve( - Response.json({ - access_token: `relay-token-${tokenExchangeCount}`, - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 10, - scope: RelayEnvironmentStatusScope, - }), - ); - } - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - descriptor: { - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }), - ); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const statusInput = { - clerkToken: "clerk-token", - scopes: [RelayEnvironmentStatusScope], - environmentId: EnvironmentId.make("env-1"), - } as const; - - yield* relayClient.getEnvironmentStatus(statusInput); - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(1); - - yield* TestClock.adjust(Duration.seconds(6)); - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(2); - - yield* relayClient.resetTokenCache; - yield* relayClient.getEnvironmentStatus(statusInput); - expect(tokenExchangeCount).toBe(3); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); - }); - - it.effect("times out stalled relay environment listing requests", () => { - const fetchFn = (() => - new Promise(() => undefined)) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const errorFiber = yield* relayClient - .listEnvironments({ clerkToken: "clerk-token" }) - .pipe(Effect.flip, Effect.forkScoped); - - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); - const error = yield* Fiber.join(errorFiber); - - expect(error).toMatchObject({ - _tag: "ManagedRelayClientError", - message: "Relay environment listing timed out.", - }); - }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); - }); - - it.effect("lists account devices through the Clerk bearer client endpoint", () => { - const fetchFn = ((input, init) => { - expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); - expect(init?.headers).toMatchObject({ - authorization: "Bearer clerk-token", - }); - return Promise.resolve( - Response.json({ - devices: [ - { - deviceId: "device-1", - label: "Julius's iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: "1.0.0", - notifications: { - enabled: false, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - liveActivities: { - enabled: true, - }, - updatedAt: "2026-06-01T00:00:00.000Z", - }, - ], - }), - ); - }) satisfies typeof globalThis.fetch; - - return Effect.gen(function* () { - const relayClient = yield* ManagedRelayClient; - const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); - expect(devices).toMatchObject([ - { - deviceId: "device-1", - label: "Julius's iPhone", - notifications: { - enabled: false, - }, - }, - ]); - }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); - }); -}); diff --git a/packages/client-runtime/src/managedRelay.ts b/packages/client-runtime/src/managedRelay.ts deleted file mode 100644 index f4b9b1f9353..00000000000 --- a/packages/client-runtime/src/managedRelay.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { - RelayAccessTokenType, - RelayApi, - type RelayClientEnvironmentRecord, - type RelayClientDeviceRecord, - RelayConnectEnvironmentEndpoint, - type RelayDeviceRegistrationRequest, - type RelayDpopAccessTokenScope, - RelayDpopTokenExchangeGrantType, - type RelayEnvironmentConnectRequest, - type RelayEnvironmentConnectResponse, - type RelayEnvironmentLinkChallengeRequest, - type RelayEnvironmentLinkChallengeResponse, - type RelayEnvironmentLinkRequest, - type RelayEnvironmentLinkResponse, - type RelayEnvironmentStatusResponse, - RelayExchangeDpopAccessTokenEndpoint, - RelayGetEnvironmentStatusEndpoint, - RelayJwtSubjectTokenType, - type RelayLiveActivityRegistrationRequest, - RelayMobileRegistrationScope, - type RelayOkResponse, - type RelayPublicClientId, - RelayRegisterDeviceEndpoint, - RelayRegisterLiveActivityEndpoint, - RelayUnregisterDeviceEndpoint, -} from "@t3tools/contracts/relay"; -import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; -import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; -import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as SynchronizedRef from "effect/SynchronizedRef"; -import type { HttpMethod } from "effect/unstable/http/HttpMethod"; -import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; - -export interface ManagedRelayDpopProofInput { - readonly method: HttpMethod; - readonly url: string; - readonly accessToken?: string; -} - -export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ - readonly cause: unknown; -}> {} - -export interface ManagedRelayDpopSignerShape { - readonly thumbprint: Effect.Effect; - readonly createProof: ( - input: ManagedRelayDpopProofInput, - ) => Effect.Effect; -} - -export class ManagedRelayDpopSigner extends Context.Service< - ManagedRelayDpopSigner, - ManagedRelayDpopSignerShape ->()("@t3tools/client-runtime/managedRelay/ManagedRelayDpopSigner") {} - -export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; - -interface CachedRelayAccessToken { - readonly clerkToken: string; - readonly thumbprint: string; - readonly scopes: ReadonlyArray; - readonly accessToken: string; - readonly expiresAtMillis: number; -} - -export interface ManagedRelayAuthorization { - readonly accessToken: string; - readonly proof: string; - readonly thumbprint: string; -} - -export interface ManagedRelayClientLayerOptions { - readonly relayUrl: string; - readonly clientId: RelayPublicClientId; -} - -export interface ManagedRelayClientShape { - readonly relayUrl: string; - readonly listEnvironments: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly listDevices: (input: { - readonly clerkToken: string; - }) => Effect.Effect, ManagedRelayClientError>; - readonly createEnvironmentLinkChallenge: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkChallengeRequest; - }) => Effect.Effect; - readonly linkEnvironment: (input: { - readonly clerkToken: string; - readonly payload: RelayEnvironmentLinkRequest; - }) => Effect.Effect; - readonly unlinkEnvironment: (input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly getEnvironmentStatus: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - }) => Effect.Effect; - readonly connectEnvironment: (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly deviceId?: string; - }) => Effect.Effect; - readonly registerDevice: (input: { - readonly clerkToken: string; - readonly payload: RelayDeviceRegistrationRequest; - }) => Effect.Effect; - readonly unregisterDevice: (input: { - readonly clerkToken: string; - readonly deviceId: string; - }) => Effect.Effect; - readonly registerLiveActivity: (input: { - readonly clerkToken: string; - readonly payload: RelayLiveActivityRegistrationRequest; - }) => Effect.Effect; - readonly resetTokenCache: Effect.Effect; -} - -export class ManagedRelayClient extends Context.Service< - ManagedRelayClient, - ManagedRelayClientShape ->()("@t3tools/client-runtime/managedRelay/ManagedRelayClient") {} - -function relayClientError(message: string, cause?: unknown): ManagedRelayClientError { - return new ManagedRelayClientError({ message, ...(cause === undefined ? {} : { cause }) }); -} - -function timeoutRelayRequest(message: string) { - return ( - request: Effect.Effect, - ): Effect.Effect => - request.pipe( - Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(relayClientError(message)), - onSome: Effect.succeed, - }), - ), - ); -} - -function tokenMatches( - token: CachedRelayAccessToken, - input: { - readonly clerkToken: string; - readonly thumbprint: string; - readonly scopes: ReadonlyArray; - readonly nowMillis: number; - }, -): boolean { - return ( - token.clerkToken === input.clerkToken && - token.thumbprint === input.thumbprint && - token.expiresAtMillis > input.nowMillis + 5_000 && - input.scopes.every((scope) => token.scopes.includes(scope)) - ); -} - -function bearerHeaders(clerkToken: string) { - return { authorization: `Bearer ${clerkToken}` }; -} - -function dpopHeaders(authorization: ManagedRelayAuthorization) { - return { - authorization: `DPoP ${authorization.accessToken}`, - dpop: authorization.proof, - }; -} - -function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { - const unavailable = () => - Effect.fail(relayClientError("Relay URL must be a secure absolute HTTPS origin.")); - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: unavailable, - listDevices: unavailable, - createEnvironmentLinkChallenge: unavailable, - linkEnvironment: unavailable, - unlinkEnvironment: unavailable, - getEnvironmentStatus: unavailable, - connectEnvironment: unavailable, - registerDevice: unavailable, - unregisterDevice: unavailable, - registerLiveActivity: unavailable, - resetTokenCache: Effect.void, - }); -} - -export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { - return Layer.effect( - ManagedRelayClient, - Effect.gen(function* () { - const relayUrl = normalizeSecureRelayUrl(options.relayUrl); - if (relayUrl === null) { - return disabledManagedRelayClient(options.relayUrl); - } - const signer = yield* ManagedRelayDpopSigner; - const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); - const cachedTokens = yield* SynchronizedRef.make>([]); - const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); - - type DpopProofTarget = Pick; - const dpopProofTargets = { - exchangeAccessToken: (): DpopProofTarget => ({ - method: RelayExchangeDpopAccessTokenEndpoint.method, - url: urlBuilder.token.exchangeDpopAccessToken(), - }), - getEnvironmentStatus: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayGetEnvironmentStatusEndpoint.method, - url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), - }), - connectEnvironment: ( - environmentId: RelayClientEnvironmentRecord["environmentId"], - ): DpopProofTarget => ({ - method: RelayConnectEnvironmentEndpoint.method, - url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), - }), - registerDevice: (): DpopProofTarget => ({ - method: RelayRegisterDeviceEndpoint.method, - url: urlBuilder.mobile.registerDevice(), - }), - unregisterDevice: (deviceId: string): DpopProofTarget => ({ - method: RelayUnregisterDeviceEndpoint.method, - url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), - }), - registerLiveActivity: (): DpopProofTarget => ({ - method: RelayRegisterLiveActivityEndpoint.method, - url: urlBuilder.mobile.registerLiveActivity(), - }), - }; - - const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( - function* (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly thumbprint: string; - }) { - const nowMillis = yield* Clock.currentTimeMillis; - return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => { - const activeTokens = tokens.filter( - (token) => token.expiresAtMillis > nowMillis + 5_000, - ); - const cached = activeTokens.find((token) => - tokenMatches(token, { ...input, nowMillis }), - ); - if (cached) { - return Effect.succeed([cached, activeTokens] as const); - } - return Effect.gen(function* () { - const proof = yield* signer - .createProof(dpopProofTargets.exchangeAccessToken()) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay token DPoP proof.", cause), - ), - ); - const response = yield* client.token - .exchangeDpopAccessToken({ - headers: { dpop: proof }, - payload: { - grant_type: RelayDpopTokenExchangeGrantType, - subject_token: input.clerkToken, - subject_token_type: RelayJwtSubjectTokenType, - requested_token_type: RelayAccessTokenType, - resource: relayUrl, - scope: encodeOAuthScope(input.scopes), - client_id: options.clientId, - }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not exchange relay DPoP access token.", cause), - ), - timeoutRelayRequest("Relay DPoP access token exchange timed out."), - ); - if (!oauthScopeSetEquals(response.scope, input.scopes)) { - return yield* relayClientError( - "Relay granted unexpected DPoP access token scopes.", - ); - } - const next: CachedRelayAccessToken = { - clerkToken: input.clerkToken, - thumbprint: input.thumbprint, - scopes: input.scopes, - accessToken: response.access_token, - expiresAtMillis: nowMillis + response.expires_in * 1_000, - }; - return [next, [...activeTokens, next]] as const; - }); - }); - }, - ); - - const authorize = (input: { - readonly clerkToken: string; - readonly scopes: ReadonlyArray; - readonly target: DpopProofTarget; - }) => - Effect.gen(function* () { - const thumbprint = yield* signer.thumbprint.pipe( - Effect.mapError((cause) => - relayClientError("Could not load relay DPoP proof key.", cause), - ), - ); - const token = yield* obtainAccessToken({ - clerkToken: input.clerkToken, - scopes: input.scopes, - thumbprint, - }); - const proof = yield* signer - .createProof({ - ...input.target, - accessToken: token.accessToken, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay request DPoP proof.", cause), - ), - ); - return { accessToken: token.accessToken, proof, thumbprint }; - }); - - const authorizeMobileRegistration = (input: { - readonly clerkToken: string; - readonly target: DpopProofTarget; - }) => - authorize({ - ...input, - scopes: [RelayMobileRegistrationScope], - }); - - return ManagedRelayClient.of({ - relayUrl, - listEnvironments: (input) => - client.client.listEnvironments({ headers: bearerHeaders(input.clerkToken) }).pipe( - Effect.map((response) => response.environments), - Effect.mapError((cause) => - relayClientError("Could not list relay-managed environments.", cause), - ), - timeoutRelayRequest("Relay environment listing timed out."), - withRelayClientTracing, - ), - listDevices: (input) => - client.client - .listDevices({ - headers: bearerHeaders(input.clerkToken), - }) - .pipe( - Effect.map((response) => response.devices), - Effect.mapError((cause) => - relayClientError("Could not list relay client devices.", cause), - ), - timeoutRelayRequest("Relay client device listing timed out."), - withRelayClientTracing, - ), - createEnvironmentLinkChallenge: (input) => - client.client - .createEnvironmentLinkChallenge({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not create relay environment link challenge.", cause), - ), - timeoutRelayRequest("Relay environment link challenge timed out."), - withRelayClientTracing, - ), - linkEnvironment: (input) => - client.client - .linkEnvironment({ - headers: bearerHeaders(input.clerkToken), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not link relay environment.", cause), - ), - timeoutRelayRequest("Relay environment linking timed out."), - withRelayClientTracing, - ), - unlinkEnvironment: (input) => - client.client - .unlinkEnvironment({ - headers: bearerHeaders(input.clerkToken), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not unlink relay environment.", cause), - ), - timeoutRelayRequest("Relay environment unlinking timed out."), - withRelayClientTracing, - ), - getEnvironmentStatus: (input) => - Effect.gen(function* () { - const authorization = yield* authorize({ - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.getEnvironmentStatus(input.environmentId), - }); - return yield* client.dpopClient - .getEnvironmentStatus({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not get relay environment status.", cause), - ), - timeoutRelayRequest("Relay environment status request timed out."), - ); - }).pipe(withRelayClientTracing), - connectEnvironment: (input) => - Effect.gen(function* () { - const authorization = yield* authorize({ - clerkToken: input.clerkToken, - scopes: input.scopes, - target: dpopProofTargets.connectEnvironment(input.environmentId), - }); - const payload: RelayEnvironmentConnectRequest = { - ...(input.deviceId ? { deviceId: input.deviceId } : {}), - clientKeyThumbprint: authorization.thumbprint, - }; - return yield* client.dpopClient - .connectEnvironment({ - headers: dpopHeaders(authorization), - params: { environmentId: input.environmentId }, - payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not connect relay environment.", cause), - ), - timeoutRelayRequest("Relay environment connection timed out."), - ); - }).pipe(withRelayClientTracing), - registerDevice: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.registerDevice(), - }); - return yield* client.mobile - .registerDevice({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not register relay mobile device.", cause), - ), - timeoutRelayRequest("Relay mobile device registration timed out."), - ); - }).pipe(withRelayClientTracing), - unregisterDevice: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.unregisterDevice(input.deviceId), - }); - return yield* client.mobile - .unregisterDevice({ - headers: dpopHeaders(authorization), - params: { deviceId: input.deviceId }, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not unregister relay mobile device.", cause), - ), - timeoutRelayRequest("Relay mobile device unregistration timed out."), - ); - }).pipe(withRelayClientTracing), - registerLiveActivity: (input) => - Effect.gen(function* () { - const authorization = yield* authorizeMobileRegistration({ - clerkToken: input.clerkToken, - target: dpopProofTargets.registerLiveActivity(), - }); - return yield* client.mobile - .registerLiveActivity({ - headers: dpopHeaders(authorization), - payload: input.payload, - }) - .pipe( - Effect.mapError((cause) => - relayClientError("Could not register relay live activity.", cause), - ), - timeoutRelayRequest("Relay Live Activity registration timed out."), - ); - }).pipe(withRelayClientTracing), - resetTokenCache: SynchronizedRef.set(cachedTokens, []), - }); - }), - ); -} diff --git a/packages/client-runtime/src/managedRelayState.test.ts b/packages/client-runtime/src/managedRelayState.test.ts deleted file mode 100644 index ce58241e796..00000000000 --- a/packages/client-runtime/src/managedRelayState.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { EnvironmentId } from "@t3tools/contracts"; -import type { - RelayClientDeviceRecord, - RelayClientEnvironmentRecord, - RelayEnvironmentStatusResponse, -} from "@t3tools/contracts/relay"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { ManagedRelayClient, type ManagedRelayClientShape } from "./managedRelay.ts"; -import { - createManagedRelayQueryManager, - createManagedRelaySession, - managedRelaySessionAtom, - readManagedRelaySnapshotState, - setManagedRelaySession, - waitForManagedRelayClerkToken, -} from "./managedRelayState.ts"; - -let registry = AtomRegistry.make(); - -const environment = { - environmentId: EnvironmentId.make("environment-1"), - label: "Main environment", - endpoint: { - httpBaseUrl: "https://environment.example.test", - wsBaseUrl: "wss://environment.example.test", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-06-01T00:00:00.000Z", -} satisfies RelayClientEnvironmentRecord; - -const device = { - deviceId: "device-1", - label: "Julius iPhone", - platform: "ios", - iosMajorVersion: 18, - appVersion: null, - notifications: { - enabled: true, - notifyOnApproval: true, - notifyOnInput: true, - notifyOnCompletion: true, - notifyOnFailure: true, - }, - liveActivities: { - enabled: true, - }, - updatedAt: "2026-06-01T00:00:00.000Z", -} satisfies RelayClientDeviceRecord; - -function resetRegistry() { - registry.dispose(); - registry = AtomRegistry.make(); -} - -function createManager(overrides?: Partial) { - const client = ManagedRelayClient.of({ - relayUrl: "https://relay.example.test", - listEnvironments: () => Effect.succeed([environment]), - listDevices: () => Effect.succeed([device]), - createEnvironmentLinkChallenge: () => Effect.die("unused"), - linkEnvironment: () => Effect.die("unused"), - unlinkEnvironment: () => Effect.die("unused"), - getEnvironmentStatus: () => - Effect.succeed({ - environmentId: environment.environmentId, - endpoint: environment.endpoint, - status: "online", - checkedAt: "2026-06-01T00:00:00.000Z", - }), - connectEnvironment: () => Effect.die("unused"), - registerDevice: () => Effect.die("unused"), - unregisterDevice: () => Effect.die("unused"), - registerLiveActivity: () => Effect.die("unused"), - resetTokenCache: Effect.void, - ...overrides, - }); - const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); - return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000 }); -} - -function setSession() { - setManagedRelaySession( - registry, - createManagedRelaySession({ - accountId: "account-1", - readClerkToken: () => Promise.resolve("clerk-token"), - }), - ); -} - -describe("createManagedRelayQueryManager", () => { - afterEach(resetRegistry); - - it("waits for the current cloud session before reading its token", async () => { - const token = Effect.runPromise(waitForManagedRelayClerkToken(registry)); - - setSession(); - - await expect(token).resolves.toBe("clerk-token"); - expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); - }); - - it("keeps environment snapshots cached and refreshes them explicitly", async () => { - const listEnvironments = vi.fn(() => Effect.succeed([environment])); - const manager = createManager({ listEnvironments }); - setSession(); - const atom = manager.environmentsAtom("account-1"); - - registry.get(atom); - await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); - - registry.get(manager.environmentsAtom("account-1")); - expect(listEnvironments).toHaveBeenCalledTimes(1); - - manager.refreshEnvironments(registry, "account-1"); - await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); - }); - - it("loads device snapshots through the current account session", async () => { - const listDevices = vi.fn(() => Effect.succeed([device])); - const manager = createManager({ listDevices }); - setSession(); - const atom = manager.devicesAtom("account-1"); - - registry.get(atom); - await vi.waitFor(() => { - expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); - }); - }); - - it("rejects status responses for a different environment", async () => { - const mismatchedStatus = { - environmentId: EnvironmentId.make("environment-2"), - endpoint: environment.endpoint, - status: "online", - checkedAt: "2026-06-01T00:00:00.000Z", - } satisfies RelayEnvironmentStatusResponse; - const manager = createManager({ - getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), - }); - setSession(); - const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); - - registry.get(atom); - await vi.waitFor(() => { - expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( - "Relay returned status for a different environment.", - ); - }); - }); -}); diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts new file mode 100644 index 00000000000..e7e59dd85d4 --- /dev/null +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -0,0 +1,140 @@ +import { + CommandId, + EnvironmentId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ThreadId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { archiveThread, createProject, stopThreadSession } from "./commands.ts"; + +const TEST_CRYPTO_LAYER = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: (_algorithm, data) => Effect.succeed(data), + }), +); + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(function* ( + dispatched: ClientOrchestrationCommand[], +) { + const client = { + [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command: ClientOrchestrationCommand) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session)), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisorService); +}); + +describe("environment commands", () => { + it.effect("adds generated command metadata", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + const result = yield* createProject({ + projectId: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/workspace/project", + createdAt: "2026-06-06T00:00:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + + expect(result).toEqual({ sequence: 1 }); + expect(dispatched).toEqual([ + { + type: "project.create", + commandId: "00000000-0000-4000-8000-000000000000", + projectId: "project-1", + title: "Project", + workspaceRoot: "/workspace/project", + createdAt: "2026-06-06T00:00:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("preserves caller metadata for idempotent queued commands", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* stopThreadSession({ + commandId: CommandId.make("queued-command"), + threadId: ThreadId.make("thread-1"), + createdAt: "2026-06-06T00:01:00.000Z", + }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.session.stop", + commandId: "queued-command", + threadId: "thread-1", + createdAt: "2026-06-06T00:01:00.000Z", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); + + it.effect("does not add timestamps to commands without createdAt", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + + yield* archiveThread({ + commandId: CommandId.make("archive-command"), + threadId: ThreadId.make("thread-1"), + }).pipe(Effect.provideService(EnvironmentSupervisor, supervisor)); + + expect(dispatched).toEqual([ + { + type: "thread.archive", + commandId: "archive-command", + threadId: "thread-1", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); +}); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts new file mode 100644 index 00000000000..a0c3cbe771f --- /dev/null +++ b/packages/client-runtime/src/operations/commands.ts @@ -0,0 +1,256 @@ +import { + CommandId, + ORCHESTRATION_WS_METHODS, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import type { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { + type EnvironmentRpcFailure, + type EnvironmentRpcSuccess, + type EnvironmentRpcUnavailableError, + request, +} from "../rpc/client.ts"; + +type CommandType = ClientOrchestrationCommand["type"]; +type CommandOf = Extract; +type CommandInput = Omit< + CommandOf, + "type" | "commandId" | "createdAt" +> & { + readonly commandId?: CommandId; +} & ("createdAt" extends keyof CommandOf + ? { + readonly createdAt?: CommandOf["createdAt"]; + } + : {}); + +export type CreateProjectInput = CommandInput<"project.create">; +export type UpdateProjectInput = CommandInput<"project.meta.update">; +export type DeleteProjectInput = CommandInput<"project.delete">; +export type CreateThreadInput = CommandInput<"thread.create">; +export type DeleteThreadInput = CommandInput<"thread.delete">; +export type ArchiveThreadInput = CommandInput<"thread.archive">; +export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; +export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; +export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; +export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; +export type StartThreadTurnInput = CommandInput<"thread.turn.start">; +export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; +export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; +export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; +export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; +export type StopThreadSessionInput = CommandInput<"thread.session.stop">; + +type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; +type CommandEffect = Effect.Effect< + EnvironmentRpcSuccess, + EnvironmentRpcFailure | EnvironmentRpcUnavailableError, + Crypto.Crypto | EnvironmentSupervisor +>; + +function commandId(input: { readonly commandId?: CommandId }) { + return Effect.gen(function* () { + if (input.commandId !== undefined) { + return input.commandId; + } + const crypto = yield* Crypto.Crypto; + return yield* crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(CommandId.make)); + }); +} + +function timestampedCommandMetadata(input: { + readonly commandId?: CommandId; + readonly createdAt?: string; +}) { + return Effect.all({ + commandId: commandId(input), + createdAt: + input.createdAt === undefined + ? DateTime.now.pipe(Effect.map(DateTime.formatIso)) + : Effect.succeed(input.createdAt), + }); +} + +function dispatch(command: ClientOrchestrationCommand) { + return request(ORCHESTRATION_WS_METHODS.dispatchCommand, command); +} + +export const createProject: (input: CreateProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.createProject", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "project.create", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const updateProject: (input: UpdateProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateProject", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "project.meta.update", + commandId: yield* commandId(input), + }); +}); + +export const deleteProject: (input: DeleteProjectInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.deleteProject", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "project.delete", + commandId: yield* commandId(input), + }); +}); + +export const createThread: (input: CreateThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.createThread", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.create", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.deleteThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.delete", + commandId: yield* commandId(input), + }); +}); + +export const archiveThread: (input: ArchiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.archiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.archive", + commandId: yield* commandId(input), + }); +}); + +export const unarchiveThread: (input: UnarchiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.unarchiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.unarchive", + commandId: yield* commandId(input), + }); +}); + +export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.updateThreadMetadata", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.meta.update", + commandId: yield* commandId(input), + }); +}); + +export const setThreadRuntimeMode: (input: SetThreadRuntimeModeInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.setThreadRuntimeMode", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.runtime-mode.set", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const setThreadInteractionMode: (input: SetThreadInteractionModeInput) => CommandEffect = + Effect.fn("EnvironmentCommands.setThreadInteractionMode")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.interaction-mode.set", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.startThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.start", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.interruptThreadTurn", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.turn.interrupt", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + +export const respondToThreadApproval: (input: RespondToThreadApprovalInput) => CommandEffect = + Effect.fn("EnvironmentCommands.respondToThreadApproval")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.approval.respond", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const respondToThreadUserInput: (input: RespondToThreadUserInputInput) => CommandEffect = + Effect.fn("EnvironmentCommands.respondToThreadUserInput")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.user-input.respond", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const revertThreadCheckpoint: (input: RevertThreadCheckpointInput) => CommandEffect = + Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.checkpoint.revert", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + +export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.stopThreadSession", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.session.stop", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); diff --git a/packages/client-runtime/src/operations/index.ts b/packages/client-runtime/src/operations/index.ts new file mode 100644 index 00000000000..b7307fbb81f --- /dev/null +++ b/packages/client-runtime/src/operations/index.ts @@ -0,0 +1,2 @@ +export * from "./commands.ts"; +export * from "./projects.ts"; diff --git a/packages/client-runtime/src/addProject.test.ts b/packages/client-runtime/src/operations/projects.test.ts similarity index 96% rename from packages/client-runtime/src/addProject.test.ts rename to packages/client-runtime/src/operations/projects.test.ts index fb665996a98..bf4e2c89392 100644 --- a/packages/client-runtime/src/addProject.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -14,8 +14,8 @@ import { getAddProjectInitialQuery, resolveAddProjectPath, sortAddProjectProviderSources, -} from "./addProject.ts"; -import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; +} from "./projects.ts"; +import type { EnvironmentProject } from "../state/models.ts"; describe("add project shared logic", () => { it("resolves initial browse paths from settings", () => { @@ -92,7 +92,7 @@ describe("add project shared logic", () => { it("finds existing projects by normalized path in the target environment", () => { const env = EnvironmentId.make("env"); const other = EnvironmentId.make("other"); - const projects: EnvironmentScopedProjectShell[] = [ + const projects: EnvironmentProject[] = [ { environmentId: other, id: ProjectId.make("same-path-other-env"), diff --git a/packages/client-runtime/src/addProject.ts b/packages/client-runtime/src/operations/projects.ts similarity index 94% rename from packages/client-runtime/src/addProject.ts rename to packages/client-runtime/src/operations/projects.ts index fb4e599317f..ec58418a94f 100644 --- a/packages/client-runtime/src/addProject.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -19,8 +19,8 @@ import { isExplicitRelativeProjectPath, isUnsupportedWindowsProjectPath, resolveProjectPathForDispatch, -} from "./projectPaths.ts"; -import type { EnvironmentScopedProjectShell } from "./shellTypes.ts"; +} from "../state/projects.ts"; +import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, @@ -48,7 +48,7 @@ export type AddProjectCloneFlow = readonly remoteUrl: string; }; -export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ +const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ "url", "github", "gitlab", @@ -56,7 +56,7 @@ export const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = "azure-devops", ]; -export const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ +const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ "github", "gitlab", "bitbucket", @@ -190,10 +190,10 @@ export function resolveAddProjectPath(input: { } export function findExistingAddProject(input: { - readonly projects: ReadonlyArray; + readonly projects: ReadonlyArray; readonly environmentId: EnvironmentId; readonly path: string; -}): EnvironmentScopedProjectShell | null { +}): EnvironmentProject | null { return ( findProjectByPath( input.projects.filter((project) => project.environmentId === input.environmentId), diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts new file mode 100644 index 00000000000..ddc93046b37 --- /dev/null +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -0,0 +1,61 @@ +import { + type AuthClientPresentationMetadata, + type AuthEnvironmentScope, + type DesktopSshEnvironmentBootstrap, + type DesktopSshEnvironmentTarget, + EnvironmentId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; + +export interface PreparedSshEnvironment { + readonly bootstrap: DesktopSshEnvironmentBootstrap; + readonly bearerToken: string; +} + +export interface ProvisionedSshEnvironment extends PreparedSshEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +export class CloudSession extends Context.Service< + CloudSession, + { + readonly clerkToken: Effect.Effect; + } +>()("@t3tools/client-runtime/platform/capabilities/CloudSession") {} + +export class RelayDeviceIdentity extends Context.Service< + RelayDeviceIdentity, + { + readonly deviceId: Effect.Effect, ConnectionAttemptError>; + } +>()("@t3tools/client-runtime/platform/capabilities/RelayDeviceIdentity") {} + +export class ClientPresentation extends Context.Service< + ClientPresentation, + { + readonly metadata: AuthClientPresentationMetadata; + readonly scopes: ReadonlyArray; + } +>()("@t3tools/client-runtime/platform/capabilities/ClientPresentation") {} + +export class SshEnvironmentGateway extends Context.Service< + SshEnvironmentGateway, + { + readonly provision: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + readonly prepare: (input: { + readonly connectionId: string; + readonly expectedEnvironmentId: EnvironmentId; + readonly target: DesktopSshEnvironmentTarget; + }) => Effect.Effect; + readonly disconnect: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} diff --git a/packages/client-runtime/src/platform/index.ts b/packages/client-runtime/src/platform/index.ts new file mode 100644 index 00000000000..0c937549771 --- /dev/null +++ b/packages/client-runtime/src/platform/index.ts @@ -0,0 +1,4 @@ +export * from "./capabilities.ts"; +export * from "./persistence.ts"; +export * from "./source.ts"; +export * from "./storageDocument.ts"; diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts new file mode 100644 index 00000000000..71664bf4601 --- /dev/null +++ b/packages/client-runtime/src/platform/persistence.ts @@ -0,0 +1,84 @@ +import { + type EnvironmentId, + type OrchestrationThread, + type OrchestrationShellSnapshot, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ConnectionRegistration } from "../connection/catalog.ts"; +import type { ConnectionTarget } from "../connection/model.ts"; + +export class ConnectionPersistenceError extends Schema.TaggedErrorClass()( + "ConnectionPersistenceError", + { + operation: Schema.Literals([ + "list-targets", + "register-connection", + "remove-connection", + "load-shell", + "save-shell", + "load-thread", + "save-thread", + "remove-thread", + "clear-environment", + ]), + message: Schema.String, + }, +) {} + +export class ConnectionTargetStore extends Context.Service< + ConnectionTargetStore, + { + readonly list: Effect.Effect, ConnectionPersistenceError>; + } +>()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} + +export class ConnectionRegistrationStore extends Context.Service< + ConnectionRegistrationStore, + { + readonly register: ( + registration: ConnectionRegistration, + ) => Effect.Effect; + readonly remove: (target: ConnectionTarget) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} + +export class EnvironmentCacheStore extends Context.Service< + EnvironmentCacheStore, + { + readonly loadShell: ( + environmentId: EnvironmentId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveShell: ( + environmentId: EnvironmentId, + snapshot: OrchestrationShellSnapshot, + ) => Effect.Effect; + readonly loadThread: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveThread: ( + environmentId: EnvironmentId, + thread: OrchestrationThread, + ) => Effect.Effect; + readonly removeThread: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect; + readonly clear: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} + +export class EnvironmentOwnedDataCleanup extends Context.Reference<{ + readonly clear: (environmentId: EnvironmentId) => Effect.Effect; +}>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { + defaultValue: () => ({ + clear: () => Effect.void, + }), +}) {} diff --git a/packages/client-runtime/src/platform/source.ts b/packages/client-runtime/src/platform/source.ts new file mode 100644 index 00000000000..8b5bbeeea5f --- /dev/null +++ b/packages/client-runtime/src/platform/source.ts @@ -0,0 +1,11 @@ +import * as Context from "effect/Context"; +import type * as Stream from "effect/Stream"; + +import type { PrimaryConnectionRegistration } from "../connection/catalog.ts"; + +export class PlatformConnectionSource extends Context.Service< + PlatformConnectionSource, + { + readonly registrations: Stream.Stream; + } +>()("@t3tools/client-runtime/platform/source/PlatformConnectionSource") {} diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts new file mode 100644 index 00000000000..359594033f5 --- /dev/null +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -0,0 +1,146 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; +import { + BearerConnectionCredential, + BearerConnectionProfile, + BearerConnectionRegistration, + RelayConnectionRegistration, + SshConnectionProfile, + SshConnectionRegistration, +} from "../connection/catalog.ts"; +import { + BearerConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "../connection/model.ts"; +import { + EMPTY_CONNECTION_CATALOG_DOCUMENT, + registerConnectionInCatalog, + removeConnectionFromCatalog, +} from "./storageDocument.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +const BEARER_TARGET = new BearerConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + connectionId: "bearer-1", +}); +const BEARER_PROFILE = new BearerConnectionProfile({ + connectionId: BEARER_TARGET.connectionId, + environmentId: ENVIRONMENT_ID, + label: BEARER_TARGET.label, + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test", +}); +const BEARER_CREDENTIAL = new BearerConnectionCredential({ + token: "bearer-token", +}); +const REMOTE_TOKEN = new RemoteDpopAccessToken({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + endpoint: { + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test", + providerKind: "cloudflare_tunnel", + }, + accessToken: "dpop-token", + expiresAtEpochMs: 1_000_000, + dpopThumbprint: "thumbprint", +}); + +describe("ConnectionCatalogDocument", () => { + it("registers a bearer connection as one catalog mutation", () => { + const document = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + + expect(document.targets).toEqual([BEARER_TARGET]); + expect(document.profiles).toEqual([BEARER_PROFILE]); + expect(document.credentials).toEqual([ + { + connectionId: BEARER_TARGET.connectionId, + credential: BEARER_CREDENTIAL, + }, + ]); + }); + + it("replaces obsolete connection metadata without discarding a reusable DPoP token", () => { + const bearer = registerConnectionInCatalog( + { + ...EMPTY_CONNECTION_CATALOG_DOCUMENT, + remoteDpopTokens: [REMOTE_TOKEN], + }, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + const relayTarget = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + }); + const relay = registerConnectionInCatalog( + bearer, + new RelayConnectionRegistration({ target: relayTarget }), + ); + + expect(relay.targets).toEqual([relayTarget]); + expect(relay.profiles).toEqual([]); + expect(relay.credentials).toEqual([]); + expect(relay.remoteDpopTokens).toEqual([REMOTE_TOKEN]); + }); + + it("removes every catalog record owned by an explicit disconnect", () => { + const registered = registerConnectionInCatalog( + { + ...EMPTY_CONNECTION_CATALOG_DOCUMENT, + remoteDpopTokens: [REMOTE_TOKEN], + }, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ); + + expect(removeConnectionFromCatalog(registered, BEARER_TARGET)).toEqual( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + ); + }); + + it("persists the normalized SSH profile beside its target", () => { + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }, + }); + const document = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new SshConnectionRegistration({ target, profile }), + ); + + expect(document.targets).toEqual([target]); + expect(document.profiles).toEqual([profile]); + expect(document.credentials).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts new file mode 100644 index 00000000000..4eafb298e5e --- /dev/null +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -0,0 +1,141 @@ +import * as Schema from "effect/Schema"; + +import { + type ConnectionRegistration, + ConnectionCredential, + ConnectionProfile, +} from "../connection/catalog.ts"; +import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; +import { RemoteDpopAccessToken } from "../authorization/tokenStore.ts"; + +export const StoredConnectionCredential = Schema.Struct({ + connectionId: Schema.String, + credential: ConnectionCredential, +}); +export type StoredConnectionCredential = typeof StoredConnectionCredential.Type; + +export const ConnectionCatalogDocument = Schema.Struct({ + schemaVersion: Schema.Literal(1), + targets: Schema.Array(PersistedConnectionTarget), + profiles: Schema.Array(ConnectionProfile), + credentials: Schema.Array(StoredConnectionCredential), + remoteDpopTokens: Schema.Array(RemoteDpopAccessToken), +}); +export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; + +export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({ + schemaVersion: 1, + targets: [], + profiles: [], + credentials: [], + remoteDpopTokens: [], +}); + +export function replaceCatalogValue( + values: ReadonlyArray, + key: (value: A) => string, + next: A, +): ReadonlyArray { + const nextKey = key(next); + return [...values.filter((value) => key(value) !== nextKey), next]; +} + +export function removeCatalogValue( + values: ReadonlyArray, + key: (value: A) => string, + removedKey: string, +): ReadonlyArray { + return values.filter((value) => key(value) !== removedKey); +} + +function connectionIdOf(target: ConnectionTarget): string | null { + switch (target._tag) { + case "PrimaryConnectionTarget": + case "RelayConnectionTarget": + return null; + case "BearerConnectionTarget": + case "SshConnectionTarget": + return target.connectionId; + } +} + +function removeConnectionMetadata( + document: ConnectionCatalogDocument, + target: ConnectionTarget, + removeRemoteToken: boolean, +): ConnectionCatalogDocument { + const connectionId = connectionIdOf(target); + return { + ...document, + targets: removeCatalogValue( + document.targets, + (value) => value.environmentId, + target.environmentId, + ), + profiles: + connectionId === null + ? document.profiles + : removeCatalogValue(document.profiles, (value) => value.connectionId, connectionId), + credentials: + connectionId === null + ? document.credentials + : removeCatalogValue(document.credentials, (value) => value.connectionId, connectionId), + remoteDpopTokens: removeRemoteToken + ? removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + target.environmentId, + ) + : document.remoteDpopTokens, + }; +} + +export function registerConnectionInCatalog( + document: ConnectionCatalogDocument, + registration: ConnectionRegistration, +): ConnectionCatalogDocument { + const target = registration.target; + const previous = document.targets.find( + (candidate) => candidate.environmentId === target.environmentId, + ); + const cleaned = + previous === undefined ? document : removeConnectionMetadata(document, previous, false); + const next: ConnectionCatalogDocument = { + ...cleaned, + targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), + }; + + switch (registration._tag) { + case "RelayConnectionRegistration": + return next; + case "BearerConnectionRegistration": + return { + ...next, + profiles: replaceCatalogValue( + next.profiles, + (value) => value.connectionId, + registration.profile, + ), + credentials: replaceCatalogValue(next.credentials, (value) => value.connectionId, { + connectionId: registration.target.connectionId, + credential: registration.credential, + }), + }; + case "SshConnectionRegistration": + return { + ...next, + profiles: replaceCatalogValue( + next.profiles, + (value) => value.connectionId, + registration.profile, + ), + }; + } +} + +export function removeConnectionFromCatalog( + document: ConnectionCatalogDocument, + target: ConnectionTarget, +): ConnectionCatalogDocument { + return removeConnectionMetadata(document, target, true); +} diff --git a/packages/client-runtime/src/reconnectBackoff.test.ts b/packages/client-runtime/src/reconnectBackoff.test.ts deleted file mode 100644 index fb6bb415217..00000000000 --- a/packages/client-runtime/src/reconnectBackoff.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - DEFAULT_RECONNECT_BACKOFF, - getReconnectDelayMs, - type ReconnectBackoffConfig, -} from "./reconnectBackoff.ts"; - -describe("getReconnectDelayMs", () => { - it("returns exponential delays with default config", () => { - expect(getReconnectDelayMs(0)).toBe(1_000); - expect(getReconnectDelayMs(1)).toBe(2_000); - expect(getReconnectDelayMs(2)).toBe(4_000); - expect(getReconnectDelayMs(3)).toBe(8_000); - expect(getReconnectDelayMs(4)).toBe(16_000); - expect(getReconnectDelayMs(5)).toBe(32_000); - expect(getReconnectDelayMs(6)).toBe(64_000); - }); - - it("returns null when retry index exceeds maxRetries", () => { - expect(getReconnectDelayMs(7)).toBeNull(); - expect(getReconnectDelayMs(100)).toBeNull(); - }); - - it("returns null for negative indices", () => { - expect(getReconnectDelayMs(-1)).toBeNull(); - }); - - it("returns null for non-integer indices", () => { - expect(getReconnectDelayMs(1.5)).toBeNull(); - }); - - it("caps delay at maxDelayMs", () => { - const config: ReconnectBackoffConfig = { - initialDelayMs: 10_000, - backoffFactor: 10, - maxDelayMs: 30_000, - maxRetries: 5, - }; - - expect(getReconnectDelayMs(0, config)).toBe(10_000); - expect(getReconnectDelayMs(1, config)).toBe(30_000); // 100_000 capped to 30_000 - expect(getReconnectDelayMs(2, config)).toBe(30_000); // 1_000_000 capped to 30_000 - }); - - it("supports unlimited retries when maxRetries is null", () => { - const config: ReconnectBackoffConfig = { - ...DEFAULT_RECONNECT_BACKOFF, - maxRetries: null, - }; - - expect(getReconnectDelayMs(0, config)).toBe(1_000); - expect(getReconnectDelayMs(50, config)).toBe(64_000); // capped at maxDelayMs - expect(getReconnectDelayMs(100, config)).toBe(64_000); - }); -}); - -describe("DEFAULT_RECONNECT_BACKOFF", () => { - it("has sensible defaults", () => { - expect(DEFAULT_RECONNECT_BACKOFF.initialDelayMs).toBe(1_000); - expect(DEFAULT_RECONNECT_BACKOFF.backoffFactor).toBe(2); - expect(DEFAULT_RECONNECT_BACKOFF.maxDelayMs).toBe(64_000); - expect(DEFAULT_RECONNECT_BACKOFF.maxRetries).toBe(7); - }); -}); diff --git a/packages/client-runtime/src/reconnectBackoff.ts b/packages/client-runtime/src/reconnectBackoff.ts deleted file mode 100644 index 4f7ddd15a52..00000000000 --- a/packages/client-runtime/src/reconnectBackoff.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Configuration for exponential reconnect backoff. - */ -export interface ReconnectBackoffConfig { - /** Base delay in milliseconds before the first retry. */ - readonly initialDelayMs: number; - /** Multiplier applied per retry (exponential factor). */ - readonly backoffFactor: number; - /** Hard upper bound on delay in milliseconds. */ - readonly maxDelayMs: number; - /** Maximum number of retries (0-based). `null` means unlimited. */ - readonly maxRetries: number | null; -} - -/** - * Sensible defaults for WebSocket reconnect backoff. - * - * - 1 s initial delay, doubling each retry, capped at 64 s, up to 7 retries. - */ -export const DEFAULT_RECONNECT_BACKOFF: ReconnectBackoffConfig = { - initialDelayMs: 1_000, - backoffFactor: 2, - maxDelayMs: 64_000, - maxRetries: 7, -}; - -/** - * Calculate the reconnect delay for a given retry index using exponential - * backoff. Returns `null` when `retryIndex` exceeds the configured maximum. - */ -export function getReconnectDelayMs( - retryIndex: number, - config: ReconnectBackoffConfig = DEFAULT_RECONNECT_BACKOFF, -): number | null { - if (!Number.isInteger(retryIndex) || retryIndex < 0) { - return null; - } - - if (config.maxRetries !== null && retryIndex >= config.maxRetries) { - return null; - } - - return Math.min( - Math.round(config.initialDelayMs * config.backoffFactor ** retryIndex), - config.maxDelayMs, - ); -} diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts new file mode 100644 index 00000000000..cdb8341cdc7 --- /dev/null +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -0,0 +1,262 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + ManagedRelayClient, + ManagedRelayClientError, + ManagedRelayRequestTimeoutError, + type ManagedRelayClientShape, +} from "./managedRelay.ts"; +import { CloudSession } from "../platform/capabilities.ts"; +import { Connectivity } from "../connection/connectivity.ts"; +import type { NetworkStatus } from "../connection/model.ts"; +import { RelayEnvironmentDiscovery, relayEnvironmentDiscoveryLayer } from "./discovery.ts"; + +const environments = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Environment One", + endpoint: { + httpBaseUrl: "https://one.example.test", + wsBaseUrl: "wss://one.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", + }, + { + environmentId: EnvironmentId.make("environment-2"), + label: "Environment Two", + endpoint: { + httpBaseUrl: "https://two.example.test", + wsBaseUrl: "wss://two.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", + }, +] satisfies ReadonlyArray; + +function status( + environment: RelayClientEnvironmentRecord, + value: "online" | "offline", +): RelayEnvironmentStatusResponse { + return { + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: value, + checkedAt: "2026-06-01T00:00:00.000Z", + }; +} + +const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { + const networkStatus = yield* SubscriptionRef.make("online"); + const listCalls = yield* Ref.make(0); + const secondListCall = yield* Deferred.make(); + const statusRequests = yield* Ref.make( + new Map>(), + ); + for (const environment of environments) { + const request = yield* Deferred.make(); + yield* Ref.update(statusRequests, (current) => { + const next = new Map(current); + next.set(environment.environmentId, request); + return next; + }); + } + + const client = ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => + Ref.updateAndGet(listCalls, (count) => count + 1).pipe( + Effect.tap((count) => + count >= 2 ? Deferred.succeed(secondListCall, undefined) : Effect.void, + ), + Effect.as(environments), + ), + getEnvironmentStatus: ({ environmentId }) => + Ref.get(statusRequests).pipe( + Effect.flatMap((requests) => Deferred.await(requests.get(environmentId)!)), + ), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + } satisfies ManagedRelayClientShape); + const connectivity = Connectivity.of({ + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }); + const layer = relayEnvironmentDiscoveryLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ManagedRelayClient, client), + Layer.succeed(CloudSession, { + clerkToken: Effect.succeed("clerk-token"), + }), + Layer.succeed(Connectivity, connectivity), + ), + ), + ); + + return { + layer, + listCalls, + networkStatus, + secondListCall, + statusRequests, + }; +}); + +describe("RelayEnvironmentDiscovery", () => { + it.effect("publishes each environment status as soon as that lookup completes", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + const refreshFiber = yield* Effect.forkChild(discovery.refresh); + + const checking = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.environments.size === 2), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect( + [...checking.environments.values()].every((entry) => entry.availability === "checking"), + ).toBe(true); + + const requests = yield* Ref.get(harness.statusRequests); + yield* Deferred.succeed( + requests.get(environments[1]!.environmentId)!, + status(environments[1]!, "online"), + ); + + const partiallyResolved = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter( + (state) => + state.environments.get(environments[1]!.environmentId)?.availability === "online", + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect( + partiallyResolved.environments.get(environments[0]!.environmentId)?.availability, + ).toBe("checking"); + + yield* Deferred.succeed( + requests.get(environments[0]!.environmentId)!, + status(environments[0]!, "offline"), + ); + yield* Fiber.join(refreshFiber); + + const complete = yield* SubscriptionRef.get(discovery.state); + expect(complete.environments.get(environments[0]!.environmentId)?.availability).toBe( + "offline", + ); + expect(complete.refreshing).toBe(false); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect( + "preserves discovered rows while offline and refreshes after connectivity returns", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + const requests = yield* Ref.get(harness.statusRequests); + for (const environment of environments) { + yield* Deferred.succeed( + requests.get(environment.environmentId)!, + status(environment, "online"), + ); + } + yield* discovery.refresh; + + const offlineFiber = yield* SubscriptionRef.changes(discovery.state).pipe( + Stream.filter((state) => state.offline), + Stream.runHead, + Effect.forkChild, + ); + yield* SubscriptionRef.set(harness.networkStatus, "offline"); + yield* Fiber.join(offlineFiber); + expect((yield* SubscriptionRef.get(discovery.state)).environments.size).toBe(2); + + yield* SubscriptionRef.set(harness.networkStatus, "online"); + yield* Deferred.await(harness.secondListCall); + expect(yield* Ref.get(harness.listCalls)).toBe(2); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("publishes listing failures without rejecting the refresh command", () => + Effect.gen(function* () { + const networkStatus = yield* SubscriptionRef.make("online"); + const client = ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => + Effect.fail( + new ManagedRelayClientError({ + message: "Relay environment listing timed out.", + cause: new ManagedRelayRequestTimeoutError({ + message: "Relay environment listing timed out.", + }), + }), + ), + getEnvironmentStatus: () => Effect.die("unused"), + listDevices: () => Effect.die("unused"), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + } satisfies ManagedRelayClientShape); + const layer = relayEnvironmentDiscoveryLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ManagedRelayClient, client), + Layer.succeed(CloudSession, { + clerkToken: Effect.succeed("clerk-token"), + }), + Layer.succeed(Connectivity, { + status: SubscriptionRef.get(networkStatus), + changes: SubscriptionRef.changes(networkStatus), + }), + ), + ), + ); + + yield* Effect.gen(function* () { + const discovery = yield* RelayEnvironmentDiscovery; + yield* discovery.refresh; + + const state = yield* SubscriptionRef.get(discovery.state); + expect(state.refreshing).toBe(false); + expect(Option.getOrThrow(state.error)).toMatchObject({ + _tag: "ConnectionTransientError", + reason: "timeout", + message: "Relay environment listing timed out.", + }); + }).pipe(Effect.provide(layer)); + }), + ); +}); diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts new file mode 100644 index 00000000000..e8e40ad9005 --- /dev/null +++ b/packages/client-runtime/src/relay/discovery.ts @@ -0,0 +1,231 @@ +import type { + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { + RelayEnvironmentConnectScope, + RelayEnvironmentStatusScope, +} from "@t3tools/contracts/relay"; +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 Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { ManagedRelayClient } from "./managedRelay.ts"; +import { CloudSession } from "../platform/capabilities.ts"; +import { Connectivity } from "../connection/connectivity.ts"; +import { mapManagedRelayError } from "../connection/errors.ts"; +import { ConnectionBlockedError, type ConnectionAttemptError } from "../connection/model.ts"; + +export type RelayEnvironmentAvailability = "checking" | "online" | "offline" | "error"; + +export interface RelayDiscoveredEnvironment { + readonly environment: RelayClientEnvironmentRecord; + readonly availability: RelayEnvironmentAvailability; + readonly status: Option.Option; + readonly error: Option.Option; +} + +export interface RelayEnvironmentDiscoveryState { + readonly environments: ReadonlyMap; + readonly refreshing: boolean; + readonly offline: boolean; + readonly error: Option.Option; +} + +export interface RelayEnvironmentDiscoveryService { + readonly state: SubscriptionRef.SubscriptionRef; + readonly refresh: Effect.Effect; +} + +export class RelayEnvironmentDiscovery extends Context.Service< + RelayEnvironmentDiscovery, + RelayEnvironmentDiscoveryService +>()("@t3tools/client-runtime/relay/discovery/RelayEnvironmentDiscovery") {} + +export const EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE: RelayEnvironmentDiscoveryState = { + environments: new Map(), + refreshing: false, + offline: false, + error: Option.none(), +}; + +function validateStatus( + environment: RelayClientEnvironmentRecord, + status: RelayEnvironmentStatusResponse, +): Effect.Effect { + if (status.environmentId !== environment.environmentId) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + message: "Relay returned status for a different environment.", + }), + ); + } + if ( + status.endpoint.httpBaseUrl !== environment.endpoint.httpBaseUrl || + status.endpoint.wsBaseUrl !== environment.endpoint.wsBaseUrl || + status.endpoint.providerKind !== environment.endpoint.providerKind + ) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + message: "Relay returned status for a different environment endpoint.", + }), + ); + } + if ( + status.descriptor !== undefined && + status.descriptor.environmentId !== environment.environmentId + ) { + return Effect.fail( + new ConnectionBlockedError({ + reason: "configuration", + message: "Relay returned a descriptor for a different environment.", + }), + ); + } + return Effect.succeed(status); +} + +const makeRelayEnvironmentDiscovery = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { + const relay = yield* ManagedRelayClient; + const session = yield* CloudSession; + const connectivity = yield* Connectivity; + const state = yield* SubscriptionRef.make(EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE); + const refreshLock = yield* Semaphore.make(1); + const hasRefreshed = yield* Ref.make(false); + + const updateEnvironment = Effect.fn("RelayEnvironmentDiscovery.updateEnvironment")(function* ( + environmentId: string, + update: (current: RelayDiscoveredEnvironment) => RelayDiscoveredEnvironment, + ) { + yield* SubscriptionRef.update(state, (current) => { + const entry = current.environments.get(environmentId); + if (entry === undefined) { + return current; + } + const environments = new Map(current.environments); + environments.set(environmentId, update(entry)); + return { ...current, environments }; + }); + }); + + const refreshStatus = Effect.fn("RelayEnvironmentDiscovery.refreshStatus")(function* ( + clerkToken: string, + environment: RelayClientEnvironmentRecord, + ) { + const result = yield* relay + .getEnvironmentStatus({ + clerkToken, + scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + environmentId: environment.environmentId, + }) + .pipe( + Effect.mapError(mapManagedRelayError), + Effect.flatMap((status) => validateStatus(environment, status)), + Effect.result, + ); + + if (result._tag === "Success") { + yield* updateEnvironment(environment.environmentId, (current) => ({ + ...current, + availability: result.success.status, + status: Option.some(result.success), + error: Option.none(), + })); + return; + } + + yield* updateEnvironment(environment.environmentId, (current) => ({ + ...current, + availability: "error", + error: Option.some(result.failure), + })); + }); + + const refresh = refreshLock.withPermits(1)( + Effect.gen(function* () { + yield* Ref.set(hasRefreshed, true); + if ((yield* connectivity.status) === "offline") { + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + offline: true, + })); + return; + } + + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: true, + offline: false, + error: Option.none(), + })); + + const clerkToken = yield* session.clerkToken; + const environments = yield* relay + .listEnvironments({ clerkToken }) + .pipe(Effect.mapError(mapManagedRelayError)); + const previous = (yield* SubscriptionRef.get(state)).environments; + const next = new Map(); + for (const environment of environments) { + const existing = previous.get(environment.environmentId); + next.set(environment.environmentId, { + environment, + availability: "checking", + status: existing?.status ?? Option.none(), + error: Option.none(), + }); + } + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + environments: next, + })); + + yield* Effect.forEach(environments, (environment) => refreshStatus(clerkToken, environment), { + concurrency: "unbounded", + discard: true, + }); + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + })); + }).pipe( + Effect.catch((error) => + SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + error: Option.some(error), + })), + ), + ), + ); + + yield* connectivity.changes.pipe( + Stream.changes, + Stream.runForEach((networkStatus) => + networkStatus === "offline" + ? SubscriptionRef.update(state, (current) => ({ + ...current, + refreshing: false, + offline: true, + })) + : Ref.get(hasRefreshed).pipe( + Effect.flatMap((shouldRefresh) => (shouldRefresh ? refresh : Effect.void)), + ), + ), + Effect.forkScoped, + ); + + return RelayEnvironmentDiscovery.of({ state, refresh }); +}); + +export const relayEnvironmentDiscoveryLayer = Layer.effect( + RelayEnvironmentDiscovery, + makeRelayEnvironmentDiscovery(), +); diff --git a/packages/client-runtime/src/relay/index.ts b/packages/client-runtime/src/relay/index.ts new file mode 100644 index 00000000000..8e76367c601 --- /dev/null +++ b/packages/client-runtime/src/relay/index.ts @@ -0,0 +1,3 @@ +export * from "./discovery.ts"; +export * from "./managedRelay.ts"; +export * from "./managedRelayState.ts"; diff --git a/packages/client-runtime/src/relay/managedRelay.test.ts b/packages/client-runtime/src/relay/managedRelay.test.ts new file mode 100644 index 00000000000..437793d4a4a --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelay.test.ts @@ -0,0 +1,421 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { RelayEnvironmentStatusScope } from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; + +import { + MANAGED_RELAY_REQUEST_TIMEOUT_MS, + ManagedRelayClient, + ManagedRelayDpopSigner, + managedRelayClientLayer, + type ManagedRelayAccessTokenCacheEntry, + type ManagedRelayAccessTokenStore, + type ManagedRelayDpopProofInput, +} from "./managedRelay.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; + +function managedRelayTestLayer( + fetchFn: typeof globalThis.fetch, + relayUrl = "https://relay.example.test", + accessTokenStore?: ManagedRelayAccessTokenStore, +) { + const httpClientLayer = remoteHttpClientLayer(fetchFn); + const signerLayer = Layer.succeed( + ManagedRelayDpopSigner, + ManagedRelayDpopSigner.of({ + thumbprint: Effect.succeed("client-thumbprint"), + createProof: (input: ManagedRelayDpopProofInput) => Effect.succeed(`proof:${input.url}`), + }), + ); + return managedRelayClientLayer({ + relayUrl, + clientId: "t3-mobile", + ...(accessTokenStore ? { accessTokenStore } : {}), + }).pipe(Layer.provide(signerLayer), Layer.provide(httpClientLayer)); +} + +function clerkToken(subject: string, nonce: string): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + return `${encode({ alg: "none" })}.${encode({ sub: subject, nonce })}.signature`; +} + +describe("ManagedRelayClient", () => { + it.effect("owns tracing at service and implementation boundaries", () => { + const spanNames: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spanNames.push(span.name); + return span; + }, + }); + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json({ + access_token: "relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus({ + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(spanNames).toEqual( + expect.arrayContaining([ + "clientRuntime.managedRelay.getEnvironmentStatus", + "clientRuntime.managedRelay.authorize", + "clientRuntime.managedRelay.obtainAccessToken", + "clientRuntime.managedRelay.tokenCacheCriticalSection", + "clientRuntime.managedRelay.exchangeAccessToken", + ]), + ); + expect(spanNames).not.toEqual( + expect.arrayContaining([ + "clientRuntime.managedRelay.createTokenExchangeProof", + "clientRuntime.managedRelay.exchangeAccessTokenRequest", + "clientRuntime.managedRelay.createRequestProof", + ]), + ); + }).pipe(Effect.withTracer(tracer), Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("rejects unsafe relay URLs before sending credentials", () => { + let requestCount = 0; + const fetchFn = (() => { + requestCount += 1; + return Promise.resolve(Response.json({})); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + message: "Relay URL must be a secure absolute HTTPS origin.", + }); + expect(requestCount).toBe(0); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, "http://relay.example.test"))); + }); + + it.effect("reuses usable DPoP tokens and refreshes cleared or expiring cache entries", () => { + let tokenExchangeCount = 0; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: `relay-token-${tokenExchangeCount}`, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 10, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-05-25T00:01:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const statusInput = { + clerkToken: clerkToken("user-1", "session-1"), + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + } as const; + + yield* relayClient.getEnvironmentStatus(statusInput); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(1); + + yield* TestClock.adjust(Duration.seconds(6)); + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(2); + + yield* relayClient.resetTokenCache; + yield* relayClient.getEnvironmentStatus(statusInput); + expect(tokenExchangeCount).toBe(3); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("reuses a persisted token across runtimes and Clerk session token rotation", () => { + let tokenExchangeCount = 0; + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelayAccessTokenStore = { + load: Effect.sync(() => persistedTokens), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.sync(() => { + persistedTokens = []; + }), + }; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + tokenExchangeCount += 1; + return Promise.resolve( + Response.json({ + access_token: "persisted-relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + const statusInput = (token: string) => + ({ + clerkToken: token, + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }) as const; + + return Effect.gen(function* () { + yield* Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-1"))); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + + expect(tokenExchangeCount).toBe(1); + expect(persistedTokens).toHaveLength(1); + + yield* Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus(statusInput(clerkToken("user-1", "session-2"))); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + + expect(tokenExchangeCount).toBe(1); + }); + }); + + it.effect("does not persist tokens when the Clerk subject cannot be decoded", () => { + let persistedTokens: ReadonlyArray = []; + const accessTokenStore: ManagedRelayAccessTokenStore = { + load: Effect.succeed([]), + save: (entries) => + Effect.sync(() => { + persistedTokens = entries; + }), + clear: Effect.void, + }; + const fetchFn = ((input) => { + const url = String(input); + if (url.endsWith("/v1/client/dpop-token")) { + return Promise.resolve( + Response.json({ + access_token: "relay-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 1_800, + scope: RelayEnvironmentStatusScope, + }), + ); + } + return Promise.resolve( + Response.json({ + environmentId: "env-1", + endpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + providerKind: "cloudflare_tunnel", + }, + status: "online", + checkedAt: "2026-06-05T20:00:00.000Z", + descriptor: { + environmentId: "env-1", + label: "Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + yield* relayClient.getEnvironmentStatus({ + clerkToken: "not-a-jwt", + scopes: [RelayEnvironmentStatusScope], + environmentId: EnvironmentId.make("env-1"), + }); + + expect(persistedTokens).toEqual([]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn, undefined, accessTokenStore))); + }); + + it.effect("times out stalled relay environment listing requests", () => { + const fetchFn = (() => + new Promise(() => undefined)) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const errorFiber = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)); + const error = yield* Fiber.join(errorFiber); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + message: "Relay environment listing timed out.", + }); + }).pipe(Effect.provide(Layer.merge(TestClock.layer(), managedRelayTestLayer(fetchFn)))); + }); + + it.effect("preserves typed relay trace IDs on client errors", () => { + const fetchFn = (() => + Promise.resolve( + Response.json( + { + _tag: "RelayAuthInvalidError", + code: "auth_invalid", + reason: "invalid_bearer", + traceId: "trace-managed-relay", + }, + { status: 401 }, + ), + )) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const error = yield* relayClient + .listEnvironments({ clerkToken: "clerk-token" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "ManagedRelayClientError", + traceId: "trace-managed-relay", + }); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); + + it.effect("lists account devices through the Clerk bearer client endpoint", () => { + const fetchFn = ((input, init) => { + expect(String(input)).toBe("https://relay.example.test/v1/client/devices"); + expect(init?.headers).toMatchObject({ + authorization: "Bearer clerk-token", + }); + return Promise.resolve( + Response.json({ + devices: [ + { + deviceId: "device-1", + label: "Julius's iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: "1.0.0", + notifications: { + enabled: false, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + }), + ); + }) satisfies typeof globalThis.fetch; + + return Effect.gen(function* () { + const relayClient = yield* ManagedRelayClient; + const devices = yield* relayClient.listDevices({ clerkToken: "clerk-token" }); + expect(devices).toMatchObject([ + { + deviceId: "device-1", + label: "Julius's iPhone", + notifications: { + enabled: false, + }, + }, + ]); + }).pipe(Effect.provide(managedRelayTestLayer(fetchFn))); + }); +}); diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts new file mode 100644 index 00000000000..7078facab8d --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -0,0 +1,675 @@ +import { + RelayAccessTokenType, + RelayApi, + type RelayClientEnvironmentRecord, + type RelayClientDeviceRecord, + RelayConnectEnvironmentEndpoint, + type RelayDeviceRegistrationRequest, + type RelayDpopAccessTokenScope, + RelayDpopTokenExchangeGrantType, + type RelayEnvironmentConnectRequest, + type RelayEnvironmentConnectResponse, + type RelayEnvironmentLinkChallengeRequest, + type RelayEnvironmentLinkChallengeResponse, + type RelayEnvironmentLinkRequest, + type RelayEnvironmentLinkResponse, + type RelayEnvironmentStatusResponse, + RelayExchangeDpopAccessTokenEndpoint, + RelayGetEnvironmentStatusEndpoint, + RelayJwtSubjectTokenType, + type RelayLiveActivityRegistrationRequest, + RelayMobileRegistrationScope, + type RelayOkResponse, + type RelayPublicClientId, + RelayRegisterDeviceEndpoint, + RelayRegisterLiveActivityEndpoint, + RelayProtectedError, + type RelayProtectedError as RelayProtectedErrorType, + RelayUnregisterDeviceEndpoint, +} from "@t3tools/contracts/relay"; +import { encodeOAuthScope, oauthScopeSetEquals } from "@t3tools/shared/oauthScope"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; +import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; +import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { HttpClientError } from "effect/unstable/http"; +import type { HttpMethod } from "effect/unstable/http/HttpMethod"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +export interface ManagedRelayDpopProofInput { + readonly method: HttpMethod; + readonly url: string; + readonly accessToken?: string; +} + +export class ManagedRelayDpopSignerError extends Data.TaggedError("ManagedRelayDpopSignerError")<{ + readonly cause: unknown; +}> {} + +export class ManagedRelayRequestTimeoutError extends Data.TaggedError( + "ManagedRelayRequestTimeoutError", +)<{ + readonly message: string; +}> {} + +type RelayHttpRequestError = + | RelayProtectedErrorType + | HttpClientError.HttpClientError + | Schema.SchemaError + | ManagedRelayRequestTimeoutError; + +export interface ManagedRelayDpopSignerShape { + readonly thumbprint: Effect.Effect; + readonly createProof: ( + input: ManagedRelayDpopProofInput, + ) => Effect.Effect; +} + +export class ManagedRelayDpopSigner extends Context.Service< + ManagedRelayDpopSigner, + ManagedRelayDpopSignerShape +>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayDpopSigner") {} + +export class ManagedRelayClientError extends Data.TaggedError("ManagedRelayClientError")<{ + readonly message: string; + readonly cause?: RelayHttpRequestError | ManagedRelayDpopSignerError; + readonly relayError?: RelayProtectedErrorType; + readonly traceId?: string; +}> {} + +export const MANAGED_RELAY_REQUEST_TIMEOUT_MS = 10_000; + +export interface ManagedRelayAccessTokenCacheEntry { + readonly accountId: string; + readonly clientId: RelayPublicClientId; + readonly relayUrl: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly accessToken: string; + readonly expiresAtMillis: number; +} + +export interface ManagedRelayAccessTokenStore { + readonly load: Effect.Effect>; + readonly save: (entries: ReadonlyArray) => Effect.Effect; + readonly clear: Effect.Effect; +} + +export interface ManagedRelayAuthorization { + readonly accessToken: string; + readonly proof: string; + readonly thumbprint: string; +} + +export interface ManagedRelayClientLayerOptions { + readonly relayUrl: string; + readonly clientId: RelayPublicClientId; + readonly accessTokenStore?: ManagedRelayAccessTokenStore; +} + +export interface ManagedRelayClientShape { + readonly relayUrl: string; + readonly listEnvironments: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly listDevices: (input: { + readonly clerkToken: string; + }) => Effect.Effect, ManagedRelayClientError>; + readonly createEnvironmentLinkChallenge: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkChallengeRequest; + }) => Effect.Effect; + readonly linkEnvironment: (input: { + readonly clerkToken: string; + readonly payload: RelayEnvironmentLinkRequest; + }) => Effect.Effect; + readonly unlinkEnvironment: (input: { + readonly clerkToken: string; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly getEnvironmentStatus: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + }) => Effect.Effect; + readonly connectEnvironment: (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; + readonly deviceId?: string; + }) => Effect.Effect; + readonly registerDevice: (input: { + readonly clerkToken: string; + readonly payload: RelayDeviceRegistrationRequest; + }) => Effect.Effect; + readonly unregisterDevice: (input: { + readonly clerkToken: string; + readonly deviceId: string; + }) => Effect.Effect; + readonly registerLiveActivity: (input: { + readonly clerkToken: string; + readonly payload: RelayLiveActivityRegistrationRequest; + }) => Effect.Effect; + readonly resetTokenCache: Effect.Effect; +} + +export class ManagedRelayClient extends Context.Service< + ManagedRelayClient, + ManagedRelayClientShape +>()("@t3tools/client-runtime/relay/managedRelay/ManagedRelayClient") {} + +const isRelayProtectedError = Schema.is(RelayProtectedError); + +function relayClientError(message: string, cause?: RelayHttpRequestError): ManagedRelayClientError { + return new ManagedRelayClientError({ + message, + ...(cause === undefined ? {} : { cause }), + }); +} + +function relayLocalError( + message: string, + cause: ManagedRelayDpopSignerError, +): ManagedRelayClientError { + return new ManagedRelayClientError({ message, cause }); +} + +function relayRequestError(message: string) { + return (cause: RelayHttpRequestError): ManagedRelayClientError => + new ManagedRelayClientError({ + message, + cause, + ...(isRelayProtectedError(cause) ? { relayError: cause, traceId: cause.traceId } : {}), + }); +} + +function timeoutRelayRequest(message: string) { + return ( + request: Effect.Effect, + ): Effect.Effect => + request.pipe( + Effect.timeoutOption(Duration.millis(MANAGED_RELAY_REQUEST_TIMEOUT_MS)), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + relayClientError(message, new ManagedRelayRequestTimeoutError({ message })), + ), + onSome: Effect.succeed, + }), + ), + ); +} + +function tokenMatches( + token: ManagedRelayAccessTokenCacheEntry, + input: { + readonly accountId: string; + readonly clientId: RelayPublicClientId; + readonly relayUrl: string; + readonly thumbprint: string; + readonly scopes: ReadonlyArray; + readonly nowMillis: number; + }, +): boolean { + return ( + token.accountId === input.accountId && + token.clientId === input.clientId && + token.relayUrl === input.relayUrl && + token.thumbprint === input.thumbprint && + token.expiresAtMillis > input.nowMillis + 5_000 && + input.scopes.every((scope) => token.scopes.includes(scope)) + ); +} + +function relayAccountId(clerkToken: string): Option.Option { + try { + return Option.fromNullishOr(decodeRelayJwt(clerkToken).sub).pipe( + Option.filter((subject) => subject.length > 0), + ); + } catch { + return Option.none(); + } +} + +function bearerHeaders(clerkToken: string) { + return { authorization: `Bearer ${clerkToken}` }; +} + +function dpopHeaders(authorization: ManagedRelayAuthorization) { + return { + authorization: `DPoP ${authorization.accessToken}`, + dpop: authorization.proof, + }; +} + +function disabledManagedRelayClient(relayUrl: string): ManagedRelayClientShape { + const unavailable = (spanName: string) => + Effect.fn(spanName)(function* () { + return yield* relayClientError("Relay URL must be a secure absolute HTTPS origin."); + }); + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: unavailable("clientRuntime.managedRelay.listEnvironments"), + listDevices: unavailable("clientRuntime.managedRelay.listDevices"), + createEnvironmentLinkChallenge: unavailable( + "clientRuntime.managedRelay.createEnvironmentLinkChallenge", + ), + linkEnvironment: unavailable("clientRuntime.managedRelay.linkEnvironment"), + unlinkEnvironment: unavailable("clientRuntime.managedRelay.unlinkEnvironment"), + getEnvironmentStatus: unavailable("clientRuntime.managedRelay.getEnvironmentStatus"), + connectEnvironment: unavailable("clientRuntime.managedRelay.connectEnvironment"), + registerDevice: unavailable("clientRuntime.managedRelay.registerDevice"), + unregisterDevice: unavailable("clientRuntime.managedRelay.unregisterDevice"), + registerLiveActivity: unavailable("clientRuntime.managedRelay.registerLiveActivity"), + resetTokenCache: Effect.void.pipe( + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + ), + }); +} + +export function managedRelayClientLayer(options: ManagedRelayClientLayerOptions) { + return Layer.effect( + ManagedRelayClient, + Effect.gen(function* () { + const relayUrl = normalizeSecureRelayUrl(options.relayUrl); + if (relayUrl === null) { + return disabledManagedRelayClient(options.relayUrl); + } + const signer = yield* ManagedRelayDpopSigner; + const client = yield* HttpApiClient.make(RelayApi, { baseUrl: relayUrl }); + const initialTokens = options.accessTokenStore ? yield* options.accessTokenStore.load : []; + const cachedTokens = yield* SynchronizedRef.make< + ReadonlyArray + >(initialTokens.filter((token) => token.clientId === options.clientId)); + const urlBuilder = HttpApiClient.urlBuilder(RelayApi, { baseUrl: relayUrl }); + + type DpopProofTarget = Pick; + const dpopProofTargets = { + exchangeAccessToken: (): DpopProofTarget => ({ + method: RelayExchangeDpopAccessTokenEndpoint.method, + url: urlBuilder.token.exchangeDpopAccessToken(), + }), + getEnvironmentStatus: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayGetEnvironmentStatusEndpoint.method, + url: urlBuilder.dpopClient.getEnvironmentStatus({ params: { environmentId } }), + }), + connectEnvironment: ( + environmentId: RelayClientEnvironmentRecord["environmentId"], + ): DpopProofTarget => ({ + method: RelayConnectEnvironmentEndpoint.method, + url: urlBuilder.dpopClient.connectEnvironment({ params: { environmentId } }), + }), + registerDevice: (): DpopProofTarget => ({ + method: RelayRegisterDeviceEndpoint.method, + url: urlBuilder.mobile.registerDevice(), + }), + unregisterDevice: (deviceId: string): DpopProofTarget => ({ + method: RelayUnregisterDeviceEndpoint.method, + url: urlBuilder.mobile.unregisterDevice({ params: { deviceId } }), + }), + registerLiveActivity: (): DpopProofTarget => ({ + method: RelayRegisterLiveActivityEndpoint.method, + url: urlBuilder.mobile.registerLiveActivity(), + }), + }; + + const exchangeAccessToken = Effect.fn("clientRuntime.managedRelay.exchangeAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const proof = yield* signer + .createProof(dpopProofTargets.exchangeAccessToken()) + .pipe( + Effect.mapError((cause) => + relayLocalError("Could not create relay token DPoP proof.", cause), + ), + ); + const response = yield* client.token + .exchangeDpopAccessToken({ + headers: { dpop: proof }, + payload: { + grant_type: RelayDpopTokenExchangeGrantType, + subject_token: input.clerkToken, + subject_token_type: RelayJwtSubjectTokenType, + requested_token_type: RelayAccessTokenType, + resource: relayUrl, + scope: encodeOAuthScope(input.scopes), + client_id: options.clientId, + }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not exchange relay DPoP access token.")), + timeoutRelayRequest("Relay DPoP access token exchange timed out."), + ); + if (!oauthScopeSetEquals(response.scope, input.scopes)) { + return yield* relayClientError("Relay granted unexpected DPoP access token scopes."); + } + return response; + }, + ); + + const obtainAccessToken = Effect.fn("clientRuntime.managedRelay.obtainAccessToken")( + function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly thumbprint: string; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + }); + const nowMillis = yield* Clock.currentTimeMillis; + const accountId = relayAccountId(input.clerkToken); + if (Option.isNone(accountId)) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "bypass", + "relay.token_cache.bypass_reason": "invalid_subject_token", + }); + const response = yield* exchangeAccessToken(input); + return { + accountId: "", + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + } satisfies ManagedRelayAccessTokenCacheEntry; + } + return yield* SynchronizedRef.modifyEffect(cachedTokens, (tokens) => + Effect.gen(function* () { + const activeTokens = tokens.filter( + (token) => token.expiresAtMillis > nowMillis + 5_000, + ); + const cached = activeTokens.find((token) => + tokenMatches(token, { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + nowMillis, + }), + ); + if (cached) { + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "hit", + }); + return [cached, activeTokens] as const; + } + yield* Effect.annotateCurrentSpan({ + "relay.token_cache.result": "miss", + }); + const response = yield* exchangeAccessToken(input); + const next: ManagedRelayAccessTokenCacheEntry = { + accountId: accountId.value, + clientId: options.clientId, + relayUrl, + thumbprint: input.thumbprint, + scopes: input.scopes, + accessToken: response.access_token, + expiresAtMillis: nowMillis + response.expires_in * 1_000, + }; + const nextTokens = [...activeTokens, next]; + if (options.accessTokenStore) { + yield* options.accessTokenStore.save(nextTokens); + } + return [next, nextTokens] as const; + }), + ).pipe(Effect.withSpan("clientRuntime.managedRelay.tokenCacheCriticalSection")); + }, + ); + + const authorize = Effect.fn("clientRuntime.managedRelay.authorize")(function* (input: { + readonly clerkToken: string; + readonly scopes: ReadonlyArray; + readonly target: DpopProofTarget; + }) { + yield* Effect.annotateCurrentSpan({ + "relay.client_id": options.clientId, + "relay.scopes": input.scopes.join(" "), + "http.request.method": input.target.method, + "url.full": input.target.url, + }); + const thumbprint = yield* signer.thumbprint.pipe( + Effect.mapError((cause) => + relayLocalError("Could not load relay DPoP proof key.", cause), + ), + ); + const token = yield* obtainAccessToken({ + clerkToken: input.clerkToken, + scopes: input.scopes, + thumbprint, + }); + const proof = yield* signer + .createProof({ + ...input.target, + accessToken: token.accessToken, + }) + .pipe( + Effect.mapError((cause) => + relayLocalError("Could not create relay request DPoP proof.", cause), + ), + ); + return { accessToken: token.accessToken, proof, thumbprint }; + }); + + const authorizeMobileRegistration = (input: { + readonly clerkToken: string; + readonly target: DpopProofTarget; + }) => + authorize({ + ...input, + scopes: [RelayMobileRegistrationScope], + }); + + return ManagedRelayClient.of({ + relayUrl, + listEnvironments: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listEnvironments({ headers: bearerHeaders(input.clerkToken) }) + .pipe( + Effect.map((response) => response.environments), + Effect.mapError(relayRequestError("Could not list relay-managed environments.")), + timeoutRelayRequest("Relay environment listing timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listEnvironments"), + withRelayClientTracing, + ), + listDevices: Effect.fnUntraced( + function* (input) { + return yield* client.client + .listDevices({ + headers: bearerHeaders(input.clerkToken), + }) + .pipe( + Effect.map((response) => response.devices), + Effect.mapError(relayRequestError("Could not list relay client devices.")), + timeoutRelayRequest("Relay client device listing timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.listDevices"), + withRelayClientTracing, + ), + createEnvironmentLinkChallenge: Effect.fnUntraced( + function* (input) { + return yield* client.client + .createEnvironmentLinkChallenge({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError( + relayRequestError("Could not create relay environment link challenge."), + ), + timeoutRelayRequest("Relay environment link challenge timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.createEnvironmentLinkChallenge"), + withRelayClientTracing, + ), + linkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .linkEnvironment({ + headers: bearerHeaders(input.clerkToken), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not link relay environment.")), + timeoutRelayRequest("Relay environment linking timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.linkEnvironment"), + withRelayClientTracing, + ), + unlinkEnvironment: Effect.fnUntraced( + function* (input) { + return yield* client.client + .unlinkEnvironment({ + headers: bearerHeaders(input.clerkToken), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not unlink relay environment.")), + timeoutRelayRequest("Relay environment unlinking timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unlinkEnvironment"), + withRelayClientTracing, + ), + getEnvironmentStatus: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + const authorization = yield* authorize({ + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.getEnvironmentStatus(input.environmentId), + }); + return yield* client.dpopClient + .getEnvironmentStatus({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not get relay environment status.")), + timeoutRelayRequest("Relay environment status request timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.getEnvironmentStatus"), + withRelayClientTracing, + ), + connectEnvironment: Effect.fnUntraced( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "environment.id": input.environmentId, + }); + const authorization = yield* authorize({ + clerkToken: input.clerkToken, + scopes: input.scopes, + target: dpopProofTargets.connectEnvironment(input.environmentId), + }); + const payload: RelayEnvironmentConnectRequest = { + ...(input.deviceId ? { deviceId: input.deviceId } : {}), + clientKeyThumbprint: authorization.thumbprint, + }; + return yield* client.dpopClient + .connectEnvironment({ + headers: dpopHeaders(authorization), + params: { environmentId: input.environmentId }, + payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not connect relay environment.")), + timeoutRelayRequest("Relay environment connection timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.connectEnvironment"), + withRelayClientTracing, + ), + registerDevice: Effect.fnUntraced( + function* (input) { + const authorization = yield* authorizeMobileRegistration({ + clerkToken: input.clerkToken, + target: dpopProofTargets.registerDevice(), + }); + return yield* client.mobile + .registerDevice({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not register relay mobile device.")), + timeoutRelayRequest("Relay mobile device registration timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerDevice"), + withRelayClientTracing, + ), + unregisterDevice: Effect.fnUntraced( + function* (input) { + const authorization = yield* authorizeMobileRegistration({ + clerkToken: input.clerkToken, + target: dpopProofTargets.unregisterDevice(input.deviceId), + }); + return yield* client.mobile + .unregisterDevice({ + headers: dpopHeaders(authorization), + params: { deviceId: input.deviceId }, + }) + .pipe( + Effect.mapError(relayRequestError("Could not unregister relay mobile device.")), + timeoutRelayRequest("Relay mobile device unregistration timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.unregisterDevice"), + withRelayClientTracing, + ), + registerLiveActivity: Effect.fnUntraced( + function* (input) { + const authorization = yield* authorizeMobileRegistration({ + clerkToken: input.clerkToken, + target: dpopProofTargets.registerLiveActivity(), + }); + return yield* client.mobile + .registerLiveActivity({ + headers: dpopHeaders(authorization), + payload: input.payload, + }) + .pipe( + Effect.mapError(relayRequestError("Could not register relay live activity.")), + timeoutRelayRequest("Relay Live Activity registration timed out."), + ); + }, + Effect.withSpan("clientRuntime.managedRelay.registerLiveActivity"), + withRelayClientTracing, + ), + resetTokenCache: SynchronizedRef.set(cachedTokens, []).pipe( + Effect.andThen(options.accessTokenStore ? options.accessTokenStore.clear : Effect.void), + Effect.withSpan("clientRuntime.managedRelay.resetTokenCache"), + withRelayClientTracing, + ), + }); + }), + ); +} diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts new file mode 100644 index 00000000000..80b8fb0bba7 --- /dev/null +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -0,0 +1,313 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import type { + RelayClientDeviceRecord, + RelayClientEnvironmentRecord, + RelayEnvironmentStatusResponse, +} from "@t3tools/contracts/relay"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, vi } from "vite-plus/test"; + +import { + ManagedRelayClient, + ManagedRelayClientError, + type ManagedRelayClientShape, +} from "./managedRelay.ts"; +import { + createManagedRelayQueryManager, + createManagedRelaySession, + type ManagedRelayQueryEvent, + managedRelaySessionAtom, + readManagedRelaySnapshotState, + setManagedRelaySession, + waitForManagedRelayClerkToken, +} from "./managedRelayState.ts"; + +let registry = AtomRegistry.make(); + +const environment = { + environmentId: EnvironmentId.make("environment-1"), + label: "Main environment", + endpoint: { + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientEnvironmentRecord; + +const device = { + deviceId: "device-1", + label: "Julius iPhone", + platform: "ios", + iosMajorVersion: 18, + appVersion: null, + notifications: { + enabled: true, + notifyOnApproval: true, + notifyOnInput: true, + notifyOnCompletion: true, + notifyOnFailure: true, + }, + liveActivities: { + enabled: true, + }, + updatedAt: "2026-06-01T00:00:00.000Z", +} satisfies RelayClientDeviceRecord; + +function resetRegistry() { + registry.dispose(); + registry = AtomRegistry.make(); +} + +function createManager( + overrides?: Partial, + onQueryEvent?: (event: ManagedRelayQueryEvent) => void, +) { + const client = ManagedRelayClient.of({ + relayUrl: "https://relay.example.test", + listEnvironments: () => Effect.succeed([environment]), + listDevices: () => Effect.succeed([device]), + createEnvironmentLinkChallenge: () => Effect.die("unused"), + linkEnvironment: () => Effect.die("unused"), + unlinkEnvironment: () => Effect.die("unused"), + getEnvironmentStatus: () => + Effect.succeed({ + environmentId: environment.environmentId, + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + }), + connectEnvironment: () => Effect.die("unused"), + registerDevice: () => Effect.die("unused"), + unregisterDevice: () => Effect.die("unused"), + registerLiveActivity: () => Effect.die("unused"), + resetTokenCache: Effect.void, + ...overrides, + }); + const runtime = Atom.runtime(Layer.succeed(ManagedRelayClient, client)); + return createManagedRelayQueryManager(runtime, { + staleTimeMs: 60_000, + ...(onQueryEvent ? { onQueryEvent } : {}), + }); +} + +function setSession() { + setManagedRelaySession( + registry, + createManagedRelaySession({ + accountId: "account-1", + readClerkToken: () => Promise.resolve("clerk-token"), + }), + ); +} + +function clerkToken(expiresAtSeconds: number): string { + const encode = (value: unknown) => + btoa(JSON.stringify(value)).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); + return `${encode({ alg: "none" })}.${encode({ exp: expiresAtSeconds })}.signature`; +} + +describe("createManagedRelayQueryManager", () => { + afterEach(resetRegistry); + + it.effect("waits for the current cloud session before reading its token", () => + Effect.gen(function* () { + const tokenFiber = yield* waitForManagedRelayClerkToken(registry).pipe(Effect.forkChild); + + setSession(); + + expect(yield* Fiber.join(tokenFiber)).toBe("clerk-token"); + expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); + }), + ); + + it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => + Effect.gen(function* () { + const token = clerkToken(4_102_444_800); + let resolveToken!: (value: string) => void; + const readClerkToken = vi.fn( + () => + new Promise((resolve) => { + resolveToken = resolve; + }), + ); + const session = createManagedRelaySession({ + accountId: "account-1", + readClerkToken, + }); + + const readsFiber = yield* Effect.all([session.readClerkToken(), session.readClerkToken()], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(readClerkToken).toHaveBeenCalledTimes(1); + + resolveToken(token); + expect(yield* Fiber.join(readsFiber)).toEqual([token, token]); + expect(yield* session.readClerkToken()).toBe(token); + expect(readClerkToken).toHaveBeenCalledTimes(1); + }), + ); + + it("shares one Clerk token read across concurrent relay list and status queries", async () => { + const secondEnvironment = { + ...environment, + environmentId: EnvironmentId.make("environment-2"), + label: "Second environment", + endpoint: { + ...environment.endpoint, + httpBaseUrl: "https://environment-2.example.test", + wsBaseUrl: "wss://environment-2.example.test", + }, + } satisfies RelayClientEnvironmentRecord; + const token = clerkToken(4_102_444_800); + const readClerkToken = vi.fn(() => Promise.resolve(token)); + const manager = createManager({ + listEnvironments: () => Effect.succeed([environment, secondEnvironment]), + getEnvironmentStatus: ({ environmentId }) => { + const current = + environmentId === environment.environmentId ? environment : secondEnvironment; + return Effect.succeed({ + environmentId: current.environmentId, + endpoint: current.endpoint, + status: "online" as const, + checkedAt: "2026-06-01T00:00:00.000Z", + }); + }, + }); + setManagedRelaySession( + registry, + createManagedRelaySession({ + accountId: "account-1", + readClerkToken, + }), + ); + + const environmentsAtom = manager.environmentsAtom("account-1"); + const firstStatusAtom = manager.environmentStatusAtom({ + accountId: "account-1", + environment, + }); + const secondStatusAtom = manager.environmentStatusAtom({ + accountId: "account-1", + environment: secondEnvironment, + }); + registry.get(environmentsAtom); + registry.get(firstStatusAtom); + registry.get(secondStatusAtom); + + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(firstStatusAtom)).data?.status).toBe( + "online", + ); + expect(readManagedRelaySnapshotState(registry.get(secondStatusAtom)).data?.status).toBe( + "online", + ); + }); + expect(readClerkToken).toHaveBeenCalledTimes(1); + }); + + it("keeps environment snapshots cached and refreshes them explicitly", async () => { + const listEnvironments = vi.fn(() => Effect.succeed([environment])); + const manager = createManager({ listEnvironments }); + setSession(); + const atom = manager.environmentsAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(1)); + + registry.get(manager.environmentsAtom("account-1")); + expect(listEnvironments).toHaveBeenCalledTimes(1); + + manager.refreshEnvironments(registry, "account-1"); + await vi.waitFor(() => expect(listEnvironments).toHaveBeenCalledTimes(2)); + }); + + it("loads device snapshots through the current account session", async () => { + const listDevices = vi.fn(() => Effect.succeed([device])); + const manager = createManager({ listDevices }); + setSession(); + const atom = manager.devicesAtom("account-1"); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).data).toEqual([device]); + }); + }); + + it("reports token and relay request phases for environment status queries", async () => { + const onQueryEvent = vi.fn(); + const manager = createManager(undefined, onQueryEvent); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).data?.status).toBe("online"); + }); + + expect(onQueryEvent).toHaveBeenCalledWith({ + operation: "environment-status", + stage: "clerk-token", + phase: "start", + accountId: "account-1", + environmentId: environment.environmentId, + }); + expect(onQueryEvent).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "environment-status", + stage: "relay-request", + phase: "success", + accountId: "account-1", + environmentId: environment.environmentId, + }), + ); + }); + + it("rejects status responses for a different environment", async () => { + const mismatchedStatus = { + environmentId: EnvironmentId.make("environment-2"), + endpoint: environment.endpoint, + status: "online", + checkedAt: "2026-06-01T00:00:00.000Z", + } satisfies RelayEnvironmentStatusResponse; + const manager = createManager({ + getEnvironmentStatus: () => Effect.succeed(mismatchedStatus), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom)).error).toBe( + "Relay returned status for a different environment.", + ); + }); + }); + + it("exposes relay trace IDs alongside snapshot errors", async () => { + const manager = createManager({ + getEnvironmentStatus: () => + Effect.fail( + new ManagedRelayClientError({ + message: "Could not get relay environment status.", + traceId: "trace-status", + }), + ), + }); + setSession(); + const atom = manager.environmentStatusAtom({ accountId: "account-1", environment }); + + registry.get(atom); + await vi.waitFor(() => { + expect(readManagedRelaySnapshotState(registry.get(atom))).toMatchObject({ + error: "Could not get relay environment status.", + errorTraceId: "trace-status", + }); + }); + }); +}); diff --git a/packages/client-runtime/src/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts similarity index 60% rename from packages/client-runtime/src/managedRelayState.ts rename to packages/client-runtime/src/relay/managedRelayState.ts index f9cfab82594..4778300bc10 100644 --- a/packages/client-runtime/src/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -6,16 +6,20 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, } from "@t3tools/contracts/relay"; +import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { AsyncResult, Atom, type AtomRegistry } from "effect/unstable/reactivity"; +import { findErrorTraceId } from "../errors/errorTrace.ts"; import { ManagedRelayClient } from "./managedRelay.ts"; const DEFAULT_STALE_TIME_MS = 15_000; const DEFAULT_IDLE_TTL_MS = 5 * 60_000; +const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; export interface ManagedRelaySession { readonly accountId: string; @@ -25,9 +29,20 @@ export interface ManagedRelaySession { export interface ManagedRelaySnapshotState { readonly data: A | null; readonly error: string | null; + readonly errorTraceId: string | null; readonly isPending: boolean; } +export interface ManagedRelayQueryEvent { + readonly operation: "environments" | "devices" | "environment-status"; + readonly stage: "clerk-token" | "relay-request" | "validation"; + readonly phase: "start" | "success" | "failure"; + readonly accountId: string; + readonly environmentId?: string; + readonly message?: string; + readonly traceId?: string | null; +} + export class ManagedRelaySessionError extends Data.TaggedError("ManagedRelaySessionError")<{ readonly message: string; readonly cause?: unknown; @@ -46,17 +61,56 @@ export function createManagedRelaySession(input: { readonly accountId: string; readonly readClerkToken: () => Promise; }): ManagedRelaySession { + let cachedToken: { readonly token: string; readonly expiresAtMillis: number } | null = null; + let pendingToken: Promise | null = null; + + const readCachedClerkToken = async (nowMillis: number): Promise => { + if (cachedToken && cachedToken.expiresAtMillis > nowMillis + CLERK_TOKEN_EXPIRY_SKEW_MS) { + return cachedToken.token; + } + if (pendingToken) { + return await pendingToken; + } + + const operation = input.readClerkToken().then((token) => { + if (!token) { + cachedToken = null; + return null; + } + try { + const expiresAtSeconds = decodeRelayJwt(token).exp; + cachedToken = + typeof expiresAtSeconds === "number" + ? { token, expiresAtMillis: expiresAtSeconds * 1_000 } + : null; + } catch { + cachedToken = null; + } + return token; + }); + pendingToken = operation; + try { + return await operation; + } finally { + if (pendingToken === operation) { + pendingToken = null; + } + } + }; + return { accountId: input.accountId, - readClerkToken: () => - Effect.tryPromise({ - try: input.readClerkToken, + readClerkToken: Effect.fn("clientRuntime.managedRelaySession.readClerkToken")(function* () { + const nowMillis = yield* Clock.currentTimeMillis; + return yield* Effect.tryPromise({ + try: () => readCachedClerkToken(nowMillis), catch: (cause) => new ManagedRelaySessionError({ - message: "Could not obtain the T3 Connect session token.", + message: "Could not obtain the T3 Cloud session token.", cause, }), - }), + }); + }), }; } @@ -76,17 +130,17 @@ function readSessionClerkToken( ? Effect.succeed(token) : Effect.fail( new ManagedRelaySessionError({ - message: "The T3 Connect session token is unavailable.", + message: "The T3 Cloud session token is unavailable.", }), ), ), ); } -export function waitForManagedRelayClerkToken( - registry: AtomRegistry.AtomRegistry, -): Effect.Effect { - return Effect.callback((resume) => { +export const waitForManagedRelayClerkToken = Effect.fn( + "clientRuntime.managedRelaySession.waitForClerkToken", +)(function* (registry: AtomRegistry.AtomRegistry) { + return yield* Effect.callback((resume) => { let unsubscribe: (() => void) | undefined; let completed = false; const readCurrentSession = () => { @@ -111,7 +165,7 @@ export function waitForManagedRelayClerkToken( readCurrentSession(); return Effect.sync(() => unsubscribe?.()); }); -} +}); function requireClerkToken( get: Atom.AtomContext, @@ -121,7 +175,7 @@ function requireClerkToken( if (!session || session.accountId !== accountId) { return Effect.fail( new ManagedRelaySessionError({ - message: "Sign in to T3 Connect before loading relay data.", + message: "Sign in to T3 Cloud before loading relay data.", }), ); } @@ -188,13 +242,16 @@ export function readManagedRelaySnapshotState( result: AsyncResult.AsyncResult, ): ManagedRelaySnapshotState { let error: string | null = null; + let errorTraceId: string | null = null; if (result._tag === "Failure") { const cause = Cause.squash(result.cause); - error = cause instanceof Error ? cause.message : "Could not load T3 Connect data."; + error = cause instanceof Error ? cause.message : "Could not load T3 Cloud data."; + errorTraceId = findErrorTraceId(cause); } return { data: Option.getOrNull(AsyncResult.value(result)), error, + errorTraceId, isPending: result.waiting, }; } @@ -204,18 +261,50 @@ export function createManagedRelayQueryManager( options?: { readonly staleTimeMs?: number; readonly idleTtlMs?: number; + readonly onQueryEvent?: (event: ManagedRelayQueryEvent) => void; }, ) { const staleTime = options?.staleTimeMs ?? DEFAULT_STALE_TIME_MS; const idleTtl = options?.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; + const observe = ( + input: Omit, + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + options?.onQueryEvent?.({ ...input, phase: "start" }); + return yield* effect.pipe( + Effect.onExit((exit) => + Effect.sync(() => { + if (exit._tag === "Success") { + options?.onQueryEvent?.({ ...input, phase: "success" }); + return; + } + const error = Cause.squash(exit.cause); + options?.onQueryEvent?.({ + ...input, + phase: "failure", + message: error instanceof Error ? error.message : String(error), + traceId: findErrorTraceId(error), + }); + }), + ), + ); + }); const environmentsAtom = Atom.family((accountId: string) => runtime .atom((get) => Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); + const base = { operation: "environments" as const, accountId }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); const relay = yield* ManagedRelayClient; - return yield* relay.listEnvironments({ clerkToken }); + return yield* observe( + { ...base, stage: "relay-request" }, + relay.listEnvironments({ clerkToken }), + ); }), ) .pipe( @@ -229,9 +318,16 @@ export function createManagedRelayQueryManager( runtime .atom((get) => Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); + const base = { operation: "devices" as const, accountId }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); const relay = yield* ManagedRelayClient; - return yield* relay.listDevices({ clerkToken }); + return yield* observe( + { ...base, stage: "relay-request" }, + relay.listDevices({ clerkToken }), + ); }), ) .pipe( @@ -246,14 +342,28 @@ export function createManagedRelayQueryManager( return runtime .atom((get) => Effect.gen(function* () { - const clerkToken = yield* requireClerkToken(get, accountId); - const relay = yield* ManagedRelayClient; - const status = yield* relay.getEnvironmentStatus({ - clerkToken, - scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + const base = { + operation: "environment-status" as const, + accountId, environmentId: environment.environmentId, - }); - return yield* validateEnvironmentStatus(environment, status); + }; + const clerkToken = yield* observe( + { ...base, stage: "clerk-token" }, + requireClerkToken(get, accountId), + ); + const relay = yield* ManagedRelayClient; + const status = yield* observe( + { ...base, stage: "relay-request" }, + relay.getEnvironmentStatus({ + clerkToken, + scopes: [RelayEnvironmentStatusScope, RelayEnvironmentConnectScope], + environmentId: environment.environmentId, + }), + ); + return yield* observe( + { ...base, stage: "validation" }, + validateEnvironmentStatus(environment, status), + ); }), ) .pipe( diff --git a/packages/client-runtime/src/remote.ts b/packages/client-runtime/src/remote.ts deleted file mode 100644 index 69e6d2a54a1..00000000000 --- a/packages/client-runtime/src/remote.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { - AuthAccessTokenType, - type AuthClientPresentationMetadata, - AuthEnvironmentBootstrapTokenType, - AuthTokenExchangeGrantType, - type AuthEnvironmentScope, - EnvironmentHttpApi, - EnvironmentHttpCommonError, -} from "@t3tools/contracts"; -import type { - EnvironmentAuthInvalidError, - EnvironmentInternalError, - EnvironmentOperationForbiddenError, - EnvironmentRequestInvalidError, - EnvironmentScopeRequiredError, -} from "@t3tools/contracts"; -import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; -import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; -import * as Data from "effect/Data"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; -import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; -import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; - -const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; -const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); - -export const remoteEndpointUrl = (httpBaseUrl: string, pathname: string): string => { - const url = new URL(httpBaseUrl); - url.pathname = pathname; - url.search = ""; - url.hash = ""; - return url.toString(); -}; - -const remoteApiBaseUrl = (httpBaseUrl: string): string => { - const url = new URL(httpBaseUrl); - url.pathname = "/"; - url.search = ""; - url.hash = ""; - return url.toString(); -}; - -const clientMetadataTokenExchangeFields = ( - clientMetadata: AuthClientPresentationMetadata | undefined, -) => ({ - ...(clientMetadata?.label ? { client_label: clientMetadata.label } : {}), - ...(clientMetadata?.deviceType ? { client_device_type: clientMetadata.deviceType } : {}), - ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), -}); - -export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( - "RemoteEnvironmentAuthFetchError", -)<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( - "RemoteEnvironmentAuthInvalidJsonError", -)<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( - "RemoteEnvironmentAuthUndeclaredStatusError", -)<{ - readonly message: string; - readonly status: number; - readonly requestUrl: string; -}> { - constructor(requestUrl: string, status: number) { - super({ - message: `Remote auth endpoint ${requestUrl} returned undeclared status ${status}.`, - requestUrl, - status, - }); - } -} - -export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( - "RemoteEnvironmentAuthTimeoutError", -)<{ - readonly message: string; - readonly requestUrl: string; - readonly timeoutMs: number; -}> { - constructor(requestUrl: string, timeoutMs: number) { - super({ - message: `Remote auth endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, - requestUrl, - timeoutMs, - }); - } -} - -export type RemoteEnvironmentAuthError = - | EnvironmentRequestInvalidError - | EnvironmentAuthInvalidError - | EnvironmentScopeRequiredError - | EnvironmentOperationForbiddenError - | EnvironmentInternalError - | RemoteEnvironmentAuthFetchError - | RemoteEnvironmentAuthInvalidJsonError - | RemoteEnvironmentAuthUndeclaredStatusError - | RemoteEnvironmentAuthTimeoutError; - -export const remoteHttpClientLayer = ( - fetchFn: typeof globalThis.fetch, -): Layer.Layer => - Layer.merge( - FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), - httpHeaderRedactionLayer, - ); - -const failRemoteRequest = ( - requestUrl: string, - cause: unknown, -): Effect.Effect => { - if (cause instanceof RemoteEnvironmentAuthTimeoutError) { - return Effect.fail(cause); - } - if (isEnvironmentHttpCommonError(cause)) { - return Effect.fail(cause); - } - if (Schema.isSchemaError(cause)) { - return Effect.fail( - new RemoteEnvironmentAuthInvalidJsonError({ - message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, - cause, - }), - ); - } - if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { - const response = cause.response; - if (response.status < 200 || response.status >= 300) { - return Effect.fail( - new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), - ); - } - return Effect.fail( - new RemoteEnvironmentAuthInvalidJsonError({ - message: `Remote auth endpoint returned an invalid response from ${requestUrl}.`, - cause, - }), - ); - } - return Effect.fail( - new RemoteEnvironmentAuthFetchError({ - message: `Failed to fetch remote auth endpoint ${requestUrl} (${String(cause)}).`, - cause, - }), - ); -}; - -const executeRemoteRequest = ( - requestUrl: string, - timeoutMs: number, - request: Effect.Effect, -): Effect.Effect => - request.pipe( - Effect.timeoutOption(Duration.millis(timeoutMs)), - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), - onSome: Effect.succeed, - }), - ), - Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), - ); - -export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => - HttpApiClient.make(EnvironmentHttpApi, { - baseUrl: remoteApiBaseUrl(httpBaseUrl), - }); - -export const exchangeRemoteDpopAccessToken = Effect.fn( - "clientRuntime.remote.exchangeRemoteDpopAccessToken", -)(function* (input: { - readonly httpBaseUrl: string; - readonly credential: string; - readonly scopes?: ReadonlyArray; - readonly clientMetadata?: AuthClientPresentationMetadata; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - const response = yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ - headers: { dpop: input.dpopProof }, - payload: { - grant_type: AuthTokenExchangeGrantType, - subject_token: input.credential, - subject_token_type: AuthEnvironmentBootstrapTokenType, - requested_token_type: AuthAccessTokenType, - ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), - ...clientMetadataTokenExchangeFields(input.clientMetadata), - }, - }), - ); - return response; -}); - -export const bootstrapRemoteBearerSession = Effect.fn( - "clientRuntime.remote.bootstrapRemoteBearerSession", -)(function* (input: { - readonly httpBaseUrl: string; - readonly credential: string; - readonly scopes?: ReadonlyArray; - readonly clientMetadata?: AuthClientPresentationMetadata; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/oauth/token"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ - headers: {}, - payload: { - grant_type: AuthTokenExchangeGrantType, - subject_token: input.credential, - subject_token_type: AuthEnvironmentBootstrapTokenType, - requested_token_type: AuthAccessTokenType, - ...(input.scopes ? { scope: encodeOAuthScope(input.scopes) } : {}), - ...clientMetadataTokenExchangeFields(input.clientMetadata), - }, - }), - ); -}); - -export const fetchRemoteSessionState = Effect.fn("clientRuntime.remote.fetchRemoteSessionState")( - function* (input: { - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; - }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ - headers: { - authorization: `Bearer ${input.bearerToken}`, - }, - }), - ); - }, -); - -export const fetchRemoteDpopSessionState = Effect.fn( - "clientRuntime.remote.fetchRemoteDpopSessionState", -)(function* (input: { - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/session"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ - headers: { - authorization: `DPoP ${input.accessToken}`, - dpop: input.dpopProof, - }, - }), - ); -}); - -export const fetchRemoteEnvironmentDescriptor = Effect.fn( - "clientRuntime.remote.fetchRemoteEnvironmentDescriptor", -)(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.metadata.descriptor(), - ); -}); - -export const issueRemoteWebSocketTicket = Effect.fn( - "clientRuntime.remote.issueRemoteWebSocketTicket", -)(function* (input: { - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ - headers: { - authorization: `Bearer ${input.bearerToken}`, - }, - }), - ); -}); - -export const issueRemoteDpopWebSocketTicket = Effect.fn( - "clientRuntime.remote.issueRemoteDpopWebSocketTicket", -)(function* (input: { - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); - return yield* executeRemoteRequest( - remoteEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), - input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ - headers: { - authorization: `DPoP ${input.accessToken}`, - dpop: input.dpopProof, - }, - }), - ); -}); - -export const resolveRemoteWebSocketConnectionUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteWebSocketConnectionUrl", -)(function* (input: { - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly bearerToken: string; - readonly timeoutMs?: number; -}) { - const issued = yield* issueRemoteWebSocketTicket({ - httpBaseUrl: input.httpBaseUrl, - bearerToken: input.bearerToken, - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - }); - - const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -}); - -export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( - "clientRuntime.remote.resolveRemoteDpopWebSocketConnectionUrl", -)(function* (input: { - readonly wsBaseUrl: string; - readonly httpBaseUrl: string; - readonly accessToken: string; - readonly dpopProof: string; - readonly timeoutMs?: number; -}) { - const issued = yield* issueRemoteDpopWebSocketTicket({ - httpBaseUrl: input.httpBaseUrl, - accessToken: input.accessToken, - dpopProof: input.dpopProof, - ...(input.timeoutMs ? { timeoutMs: input.timeoutMs } : {}), - }); - const url = new URL(input.wsBaseUrl); - if (url.pathname === "" || url.pathname === "/") { - url.pathname = "/ws"; - } - url.searchParams.set("wsTicket", issued.ticket); - return url.toString(); -}); diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts new file mode 100644 index 00000000000..9191751822d --- /dev/null +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -0,0 +1,251 @@ +import { + EnvironmentId, + type RelayClientInstallProgressEvent, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { RpcClientError } from "effect/unstable/rpc"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const INSTALL_CHECKING: RelayClientInstallProgressEvent = { + type: "progress", + stage: "checking", +}; +const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { + type: "progress", + stage: "downloading", +}; + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { + const state = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>(Option.none()); + const prepared = yield* SubscriptionRef.make>(Option.none()); + const retryCount = yield* Ref.make(0); + const supervisor = EnvironmentSupervisor.of({ + target: TARGET, + state, + session: activeSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Ref.update(retryCount, (count) => count + 1), + } satisfies EnvironmentSupervisorService); + return { + activeSession, + retryCount, + supervisor, + }; +}); + +describe("environment RPC", () => { + it.effect("observes unary requests until they complete", () => + Effect.gen(function* () { + const observations: string[] = []; + const client = { + [WS_METHODS.cloudGetRelayClientStatus]: () => + Effect.succeed({ status: "available", version: "2026.6.0" }), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + + const result = yield* request(WS_METHODS.cloudGetRelayClientStatus, {}).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService( + EnvironmentRpcRequestObserver, + EnvironmentRpcRequestObserver.of({ + observe: ({ environmentId, method }) => + Effect.sync(() => { + observations.push(`start:${environmentId}:${method}`); + return Effect.sync(() => { + observations.push(`finish:${environmentId}:${method}`); + }); + }), + }), + ), + ); + + expect(result).toEqual({ status: "available", version: "2026.6.0" }); + expect(observations).toEqual([ + `start:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, + `finish:${TARGET.environmentId}:${WS_METHODS.cloudGetRelayClientStatus}`, + ]); + }), + ); + + it.effect("binds finite streaming commands to one active session", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded(); + const secondEvents = yield* Queue.unbounded(); + const firstClient = { + [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.cloudInstallRelayClient]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + const resultFiber = yield* runStream(WS_METHODS.cloudInstallRelayClient, {}).pipe( + Stream.take(2), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* Effect.yieldNow; + + yield* Queue.offer(firstEvents, INSTALL_CHECKING); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + yield* Queue.offer(secondEvents, INSTALL_DOWNLOADING); + yield* Queue.offer(firstEvents, INSTALL_DOWNLOADING); + + expect(yield* Fiber.join(resultFiber)).toEqual([INSTALL_CHECKING, INSTALL_DOWNLOADING]); + }), + ); + + it.effect("switches durable subscriptions when the supervisor replaces the session", () => + Effect.gen(function* () { + const subscriptions: string[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + const awaitSubscriptions = Effect.fn("TestEnvironmentRpc.awaitSubscriptions")(function* ( + count: number, + ) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (subscriptions.length >= count) { + return; + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error(`Expected ${count} durable subscriptions.`)); + }); + + const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + yield* awaitSubscriptions(1); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + yield* awaitSubscriptions(2); + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => + Effect.gen(function* () { + const subscriptions: string[] = []; + const firstClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("first"); + return Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "socket closed", + cause: new Error("socket closed"), + }), + }), + ); + }, + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("second"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* SubscriptionRef.set(activeSession, Option.some(session(firstClient))); + for (let attempt = 0; attempt < 100 && subscriptions.length < 1; attempt += 1) { + yield* Effect.yieldNow; + } + yield* SubscriptionRef.set(activeSession, Option.none()); + yield* SubscriptionRef.set(activeSession, Option.some(session(secondClient))); + + for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["first", "second"]); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + + it.effect("surfaces domain subscription failures without reconnecting", () => + Effect.gen(function* () { + const domainError = new Error("terminal subscription rejected"); + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => Stream.fail(domainError), + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const error = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.flip, + ); + + expect(error).toBe(domainError); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); +}); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts new file mode 100644 index 00000000000..ba16e8be68f --- /dev/null +++ b/packages/client-runtime/src/rpc/client.ts @@ -0,0 +1,214 @@ +import { ORCHESTRATION_WS_METHODS, type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { RpcClientError } from "effect/unstable/rpc"; + +import type { ConnectionAttemptError } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; + +export class EnvironmentRpcUnavailableError extends Schema.TaggedErrorClass()( + "EnvironmentRpcUnavailableError", + { + environmentId: Schema.String, + message: Schema.String, + }, +) {} + +export interface EnvironmentRpcRequestObservation { + readonly environmentId: string; + readonly method: string; +} + +export class EnvironmentRpcRequestObserver extends Context.Reference<{ + readonly observe: ( + request: EnvironmentRpcRequestObservation, + ) => Effect.Effect>; +}>("@t3tools/client-runtime/rpc/EnvironmentRpcRequestObserver", { + defaultValue: () => ({ + observe: () => Effect.succeed(Effect.void), + }), +}) {} + +export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; +type RpcMethod = WsRpcProtocolClient[TTag]; + +export type EnvironmentSubscriptionRpcTag = + | typeof ORCHESTRATION_WS_METHODS.subscribeShell + | typeof ORCHESTRATION_WS_METHODS.subscribeThread + | typeof WS_METHODS.subscribeAuthAccess + | typeof WS_METHODS.subscribeServerConfig + | typeof WS_METHODS.subscribeServerLifecycle + | typeof WS_METHODS.subscribeTerminalEvents + | typeof WS_METHODS.subscribeTerminalMetadata + | typeof WS_METHODS.subscribePreviewEvents + | typeof WS_METHODS.subscribeDiscoveredLocalServers + | typeof WS_METHODS.previewAutomationConnect + | typeof WS_METHODS.subscribeVcsStatus + | typeof WS_METHODS.terminalAttach; + +export type EnvironmentStreamCommandRpcTag = + | typeof WS_METHODS.cloudInstallRelayClient + | typeof WS_METHODS.gitRunStackedAction; + +export type EnvironmentStreamRpcTag = + | EnvironmentSubscriptionRpcTag + | EnvironmentStreamCommandRpcTag; + +export type EnvironmentUnaryRpcTag = Exclude; +const isRpcClientError = Schema.is(RpcClientError.RpcClientError); + +export type EnvironmentRpcInput = Parameters>[0]; + +export type EnvironmentRpcSuccess = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? A + : never; + +export type EnvironmentRpcFailure = + RpcMethod extends (input: any, options?: any) => Effect.Effect + ? E + : never; + +export type EnvironmentRpcStreamValue = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? A + : never; + +export type EnvironmentRpcStreamFailure = + RpcMethod extends (input: any, options?: any) => Stream.Stream + ? E + : never; + +const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { + const supervisor = yield* EnvironmentSupervisor; + return yield* SubscriptionRef.get(supervisor.session).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId: supervisor.target.environmentId, + message: `${supervisor.target.label} is not connected.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); +}); + +export const request = Effect.fn("EnvironmentRpc.request")(function* < + TTag extends EnvironmentUnaryRpcTag, +>(tag: TTag, input: EnvironmentRpcInput) { + const supervisor = yield* EnvironmentSupervisor; + yield* Effect.annotateCurrentSpan({ + "environment.id": supervisor.target.environmentId, + "rpc.method": tag, + }); + const session = yield* currentSession(); + const observer = yield* EnvironmentRpcRequestObserver; + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Effect.Effect, EnvironmentRpcFailure>; + const completeObservation = yield* observer.observe({ + environmentId: supervisor.target.environmentId, + method: tag, + }); + return yield* method(input).pipe(Effect.ensuring(completeObservation)); +}); + +export function runStream( + tag: TTag, + input: EnvironmentRpcInput, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure | EnvironmentRpcUnavailableError, + EnvironmentSupervisor +> { + return Stream.unwrap( + currentSession().pipe( + Effect.map((session) => { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream, EnvironmentRpcStreamFailure>; + return method(input); + }), + ), + ).pipe( + Stream.withSpan("EnvironmentRpc.runStream", { + attributes: { "rpc.method": tag }, + }), + ); +} + +export function subscribe( + tag: TTag, + input: EnvironmentRpcInput, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.empty, + onSome: (session) => { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure + >; + return method(input).pipe( + Stream.catchCause((cause) => { + const isTransportFailure = + cause.reasons.length > 0 && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (!isTransportFailure) { + return Stream.failCause(cause); + } + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + }), + ); + }, + }), + ), + ), + ), + ), + ).pipe( + Stream.withSpan("EnvironmentRpc.subscribe", { + attributes: { "rpc.method": tag }, + }), + ); +} + +export const config: Effect.Effect< + ServerConfig, + EnvironmentRpcUnavailableError | ConnectionAttemptError, + EnvironmentSupervisor +> = Effect.gen(function* () { + const session = yield* currentSession(); + return yield* session.initialConfig; +}).pipe(Effect.withSpan("EnvironmentRpc.config")); diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts new file mode 100644 index 00000000000..11bfa794fa7 --- /dev/null +++ b/packages/client-runtime/src/rpc/http.ts @@ -0,0 +1,154 @@ +import { + EnvironmentHttpApi, + EnvironmentHttpCommonError, + type EnvironmentAuthInvalidError, + type EnvironmentInternalError, + type EnvironmentOperationForbiddenError, + type EnvironmentRequestInvalidError, + type EnvironmentScopeRequiredError, +} from "@t3tools/contracts"; +import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; +import * as Data from "effect/Data"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { FetchHttpClient, HttpClient, HttpClientError } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); + +export class RemoteEnvironmentAuthFetchError extends Data.TaggedError( + "RemoteEnvironmentAuthFetchError", +)<{ + readonly message: string; + readonly cause: unknown; +}> {} + +export class RemoteEnvironmentAuthInvalidJsonError extends Data.TaggedError( + "RemoteEnvironmentAuthInvalidJsonError", +)<{ + readonly message: string; + readonly cause: unknown; +}> {} + +export class RemoteEnvironmentAuthUndeclaredStatusError extends Data.TaggedError( + "RemoteEnvironmentAuthUndeclaredStatusError", +)<{ + readonly message: string; + readonly status: number; + readonly requestUrl: string; +}> { + constructor(requestUrl: string, status: number) { + super({ + message: `Remote environment endpoint ${requestUrl} returned undeclared status ${status}.`, + requestUrl, + status, + }); + } +} + +export class RemoteEnvironmentAuthTimeoutError extends Data.TaggedError( + "RemoteEnvironmentAuthTimeoutError", +)<{ + readonly message: string; + readonly requestUrl: string; + readonly timeoutMs: number; +}> { + constructor(requestUrl: string, timeoutMs: number) { + super({ + message: `Remote environment endpoint ${requestUrl} timed out after ${timeoutMs}ms.`, + requestUrl, + timeoutMs, + }); + } +} + +export type RemoteEnvironmentRequestError = + | EnvironmentRequestInvalidError + | EnvironmentAuthInvalidError + | EnvironmentScopeRequiredError + | EnvironmentOperationForbiddenError + | EnvironmentInternalError + | RemoteEnvironmentAuthFetchError + | RemoteEnvironmentAuthInvalidJsonError + | RemoteEnvironmentAuthUndeclaredStatusError + | RemoteEnvironmentAuthTimeoutError; + +export const remoteHttpClientLayer = ( + fetchFn: typeof globalThis.fetch, +): Layer.Layer => + Layer.merge( + FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn))), + httpHeaderRedactionLayer, + ); + +const remoteApiBaseUrl = (httpBaseUrl: string): string => { + const url = new URL(httpBaseUrl); + url.pathname = "/"; + url.search = ""; + url.hash = ""; + return url.toString(); +}; + +export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => + HttpApiClient.make(EnvironmentHttpApi, { + baseUrl: remoteApiBaseUrl(httpBaseUrl), + }); + +const failRemoteRequest = ( + requestUrl: string, + cause: unknown, +): Effect.Effect => { + if (cause instanceof RemoteEnvironmentAuthTimeoutError) { + return Effect.fail(cause); + } + if (isEnvironmentHttpCommonError(cause)) { + return Effect.fail(cause); + } + if (Schema.isSchemaError(cause)) { + return Effect.fail( + new RemoteEnvironmentAuthInvalidJsonError({ + message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, + cause, + }), + ); + } + if (HttpClientError.isHttpClientError(cause) && cause.response !== undefined) { + const response = cause.response; + if (response.status < 200 || response.status >= 300) { + return Effect.fail( + new RemoteEnvironmentAuthUndeclaredStatusError(requestUrl, response.status), + ); + } + return Effect.fail( + new RemoteEnvironmentAuthInvalidJsonError({ + message: `Remote environment endpoint returned an invalid response from ${requestUrl}.`, + cause, + }), + ); + } + return Effect.fail( + new RemoteEnvironmentAuthFetchError({ + message: `Failed to fetch remote environment endpoint ${requestUrl} (${String(cause)}).`, + cause, + }), + ); +}; + +export const executeEnvironmentHttpRequest = ( + requestUrl: string, + timeoutMs: number, + request: Effect.Effect, +): Effect.Effect => + request.pipe( + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new RemoteEnvironmentAuthTimeoutError(requestUrl, timeoutMs)), + onSome: Effect.succeed, + }), + ), + Effect.catch((cause) => failRemoteRequest(requestUrl, cause)), + ); diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts new file mode 100644 index 00000000000..8dec2c2b2b4 --- /dev/null +++ b/packages/client-runtime/src/rpc/index.ts @@ -0,0 +1,4 @@ +export * from "./client.ts"; +export * from "./http.ts"; +export * from "./protocol.ts"; +export * from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/protocol.ts b/packages/client-runtime/src/rpc/protocol.ts new file mode 100644 index 00000000000..b8447f0d7af --- /dev/null +++ b/packages/client-runtime/src/rpc/protocol.ts @@ -0,0 +1,8 @@ +import { WsRpcGroup } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { RpcClient } from "effect/unstable/rpc"; + +export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); +type RpcClientFactory = typeof makeWsRpcProtocolClient; +export type WsRpcProtocolClient = + RpcClientFactory extends Effect.Effect ? Client : never; diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts new file mode 100644 index 00000000000..0317806f9b3 --- /dev/null +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -0,0 +1,276 @@ +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ServerConfig, + type ServerConfig as ServerConfigType, + WS_METHODS, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { + ConnectionTransientError, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { RpcSessionFactory, rpcSessionFactoryLayer } from "./session.ts"; + +type SocketEventType = "open" | "message" | "close" | "error"; +type SocketEvent = { + readonly code?: number; + readonly data?: unknown; + readonly reason?: string; + readonly type: SocketEventType; +}; +type SocketListener = (event: SocketEvent) => void; + +class TestWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readyState = TestWebSocket.CONNECTING; + readonly sent: string[] = []; + readonly url: string; + private readonly listeners = new Map>(); + + constructor(url: string) { + this.url = url; + } + + addEventListener(type: SocketEventType, listener: SocketListener) { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: SocketEventType, listener: SocketListener) { + this.listeners.get(type)?.delete(listener); + } + + send(data: string) { + this.sent.push(data); + } + + close(code = 1000, reason = "") { + if (this.readyState === TestWebSocket.CLOSED) { + return; + } + this.readyState = TestWebSocket.CLOSED; + this.emit("close", { code, reason, type: "close" }); + } + + open() { + this.readyState = TestWebSocket.OPEN; + this.emit("open", { type: "open" }); + } + + serverMessage(data: string) { + this.emit("message", { data, type: "message" }); + } + + private emit(type: SocketEventType, event: SocketEvent) { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const PREPARED: PreparedConnection = { + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl: TARGET.httpBaseUrl, + socketUrl: "wss://environment.example.test/ws?wsTicket=test", + httpAuthorization: null, + target: TARGET, +}; + +const SERVER_CONFIG: ServerConfigType = { + environment: { + environmentId: TARGET.environmentId, + label: TARGET.label, + platform: { + os: "darwin", + arch: "arm64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }, + auth: { + policy: "loopback-browser", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-access-token"], + sessionCookieName: "t3_session", + }, + cwd: "/tmp/workspace", + keybindingsConfigPath: "/tmp/workspace/keybindings.json", + keybindings: [], + issues: [], + providers: [], + availableEditors: [], + observability: { + logsDirectoryPath: "/tmp/logs", + localTracingEnabled: false, + otlpTracesEnabled: false, + otlpMetricsEnabled: false, + }, + settings: DEFAULT_SERVER_SETTINGS, +}; + +const RpcRequest = Schema.TaggedStruct("Request", { + id: Schema.String, + payload: Schema.Unknown, + tag: Schema.String, +}); +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString); +const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); +const encodeServerConfig = Schema.encodeSync(ServerConfig); + +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { + const sockets: TestWebSocket[] = []; + const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { + const socket = new TestWebSocket(url); + sockets.push(socket); + return socket as unknown as globalThis.WebSocket; + }); + const layer = rpcSessionFactoryLayer.pipe(Layer.provide(constructorLayer)); + const factory = yield* RpcSessionFactory.pipe(Effect.provide(layer)); + return { factory, sockets }; +}); + +const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( + sockets: ReadonlyArray, +) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const socket = sockets[0]; + if (socket) { + return socket; + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error("Expected the RPC protocol to create a websocket.")); +}); + +const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* ( + socket: TestWebSocket, +) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const request = socket.sent[0]; + if (request) { + return decodeRpcRequest(decodeJson(request)); + } + yield* Effect.yieldNow; + } + return yield* Effect.die(new Error("Expected the RPC protocol to send a request.")); +}); + +const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* ( + socket: TestWebSocket, +) { + const request = yield* awaitRequest(socket); + expect(request).toMatchObject({ + _tag: "Request", + tag: WS_METHODS.serverGetConfig, + payload: {}, + }); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: encodeServerConfig(SERVER_CONFIG), + }, + }), + ); +}); + +describe("RpcSessionFactory", () => { + it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + expect(socket.url).toBe(PREPARED.socketUrl); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + const config = yield* session.initialConfig; + expect(config).toEqual(SERVER_CONFIG); + expect(socket.sent).toHaveLength(1); + + socket.close(1012, "service restart"); + const error = yield* Effect.flip(session.closed); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: "Test environment disconnected.", + }); + yield* Effect.yieldNow; + expect(sockets).toHaveLength(1); + }), + ); + + it.effect("closes the websocket when the session scope is released", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + }), + ); + + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }), + ); + + it.effect("fails readiness when the websocket never opens", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + + const error = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready)); + yield* awaitSocket(sockets); + + yield* TestClock.adjust("15 seconds"); + return yield* Fiber.join(readyFiber); + }), + ); + + expect(error).toBeInstanceOf(ConnectionTransientError); + expect(error).toMatchObject({ + reason: "transport", + message: "Test environment could not establish a WebSocket connection.", + }); + expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts new file mode 100644 index 00000000000..2c97b75f829 --- /dev/null +++ b/packages/client-runtime/src/rpc/session.ts @@ -0,0 +1,144 @@ +import { type ServerConfig, WS_METHODS } 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 Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; +import type { + ConnectionAttemptError, + ConnectionTransientError, + PreparedConnection, +} from "../connection/model.ts"; +import { + ConnectionBlockedError, + ConnectionTransientError as ConnectionTransientErrorClass, +} from "../connection/model.ts"; + +const SOCKET_OPEN_TIMEOUT = "15 seconds"; + +export interface RpcSession { + readonly client: WsRpcProtocolClient; + readonly initialConfig: Effect.Effect; + readonly ready: Effect.Effect; + readonly probe: Effect.Effect; + readonly closed: Effect.Effect; +} + +export class RpcSessionFactory extends Context.Service< + RpcSessionFactory, + { + readonly connect: ( + connection: PreparedConnection, + ) => Effect.Effect; + } +>()("@t3tools/client-runtime/rpc/session/RpcSessionFactory") {} + +type InitialConfigError = Effect.Error< + ReturnType +>; + +function mapInitialConfigError(error: InitialConfigError): ConnectionAttemptError { + switch (error._tag) { + case "EnvironmentAuthorizationError": + return new ConnectionBlockedError({ + reason: "permission", + message: error.message, + }); + case "KeybindingsConfigParseError": + case "ServerSettingsError": + return new ConnectionTransientErrorClass({ + reason: "remote-unavailable", + message: error.message, + }); + case "RpcClientError": + return new ConnectionTransientErrorClass({ + reason: "transport", + message: error.message, + }); + } +} + +export const rpcSessionFactoryLayer = Layer.effect( + RpcSessionFactory, + Effect.gen(function* () { + const webSocketConstructor = yield* Socket.WebSocketConstructor; + + const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { + yield* Effect.annotateCurrentSpan({ + "connection.environment.id": connection.environmentId, + }); + + const connected = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + const hooks = RpcClient.ConnectionHooks.of({ + onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), + onDisconnect: Deferred.isDone(connected).pipe( + Effect.flatMap((wasConnected) => + Deferred.fail( + disconnected, + new ConnectionTransientErrorClass({ + reason: "transport", + message: wasConnected + ? `${connection.label} disconnected.` + : `${connection.label} could not establish a WebSocket connection.`, + }), + ), + ), + Effect.asVoid, + ), + }); + const socketLayer = Socket.layerWebSocket(connection.socketUrl, { + openTimeout: SOCKET_OPEN_TIMEOUT, + }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); + const protocolLayer = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + retryPolicy: Schedule.recurs(0), + }), + ).pipe( + Layer.provide( + Layer.mergeAll( + socketLayer, + RpcSerialization.layerJson, + Layer.succeed(RpcClient.ConnectionHooks, hooks), + ), + ), + ); + const protocolContext = yield* Layer.build(protocolLayer).pipe( + Effect.withSpan("environment.websocket.connect"), + ); + const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const initialConfig = yield* Effect.cached( + client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.withSpan("environment.initialSync"), + ), + ); + const probe = client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(mapInitialConfigError), + Effect.asVoid, + Effect.withSpan("clientRuntime.connection.rpcSession.probe"), + ); + + return { + client, + initialConfig, + ready: Deferred.await(connected).pipe( + Effect.andThen(initialConfig), + Effect.asVoid, + Effect.raceFirst(Deferred.await(disconnected)), + ), + probe, + closed: Deferred.await(disconnected), + } satisfies RpcSession; + }); + + return RpcSessionFactory.of({ connect }); + }), +); diff --git a/packages/client-runtime/src/shellSnapshotState.test.ts b/packages/client-runtime/src/shellSnapshotState.test.ts deleted file mode 100644 index f7adfee6388..00000000000 --- a/packages/client-runtime/src/shellSnapshotState.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { - EnvironmentId, - ProjectId, - ProviderInstanceId, - ThreadId, - type OrchestrationShellSnapshot, -} from "@t3tools/contracts"; - -import { createShellSnapshotManager } from "./shellSnapshotState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const BASE_SNAPSHOT: OrchestrationShellSnapshot = { - snapshotSequence: 1, - updatedAt: "2026-04-01T00:00:00.000Z", - projects: [ - { - id: ProjectId.make("project-1"), - title: "Project", - workspaceRoot: "/repo", - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - }, - ], - threads: [ - { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - }, - ], -}; - -const TARGET = { environmentId: EnvironmentId.make("env-local") } as const; - -describe("createShellSnapshotManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("starts pending when marked pending", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.markPending(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: true, - }); - }); - - it("stores snapshots", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: BASE_SNAPSHOT, - error: null, - isPending: false, - }); - }); - - it("applies incremental shell events", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - const existingThread = BASE_SNAPSHOT.threads[0]!; - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - manager.applyEvent(TARGET, { - kind: "thread-upserted", - sequence: 2, - thread: { - ...existingThread, - title: "Renamed thread", - }, - }); - - expect(manager.getSnapshot(TARGET).data?.threads[0]?.title).toBe("Renamed thread"); - expect(manager.getSnapshot(TARGET).data?.snapshotSequence).toBe(2); - }); - - it("invalidates per environment", () => { - const manager = createShellSnapshotManager({ - getRegistry: () => atomRegistry, - }); - - manager.syncSnapshot(TARGET, BASE_SNAPSHOT); - manager.invalidate(TARGET); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: false, - }); - }); -}); diff --git a/packages/client-runtime/src/shellSnapshotState.ts b/packages/client-runtime/src/shellSnapshotState.ts deleted file mode 100644 index e694d50e309..00000000000 --- a/packages/client-runtime/src/shellSnapshotState.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, -} from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; - -export interface ShellSnapshotState { - readonly data: OrchestrationShellSnapshot | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface ShellSnapshotTarget { - readonly environmentId: EnvironmentId | null; -} - -export const EMPTY_SHELL_SNAPSHOT_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_SHELL_SNAPSHOT_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -const knownShellSnapshotKeys = new Set(); - -export const shellSnapshotStateAtom = Atom.family((key: string) => { - knownShellSnapshotKeys.add(key); - return Atom.make(INITIAL_SHELL_SNAPSHOT_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`shell-snapshot:${key}`), - ); -}); - -export const EMPTY_SHELL_SNAPSHOT_ATOM = Atom.make(EMPTY_SHELL_SNAPSHOT_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("shell-snapshot:null"), -); - -export function getShellSnapshotTargetKey(target: ShellSnapshotTarget): string | null { - return target.environmentId; -} - -export interface ShellSnapshotManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; -} - -export function createShellSnapshotManager(config: ShellSnapshotManagerConfig) { - function getSnapshot(target: ShellSnapshotTarget): ShellSnapshotState { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return EMPTY_SHELL_SNAPSHOT_STATE; - } - - return config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: ShellSnapshotState): void { - config.getRegistry().set(shellSnapshotStateAtom(targetKey), nextState); - } - - function markPending(target: ShellSnapshotTarget): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: null, - isPending: true, - }); - } - - function syncSnapshot(target: ShellSnapshotTarget, snapshot: OrchestrationShellSnapshot): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - setState(targetKey, { - data: snapshot, - error: null, - isPending: false, - }); - } - - function applyEvent(target: ShellSnapshotTarget, event: OrchestrationShellStreamEvent): void { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey === null) { - return; - } - - const current = config.getRegistry().get(shellSnapshotStateAtom(targetKey)); - if (current.data === null) { - return; - } - - setState(targetKey, { - data: applyShellStreamEvent(current.data, event), - error: null, - isPending: false, - }); - } - - function invalidate(target?: ShellSnapshotTarget): void { - if (target) { - const targetKey = getShellSnapshotTargetKey(target); - if (targetKey !== null) { - setState(targetKey, EMPTY_SHELL_SNAPSHOT_STATE); - } - return; - } - - for (const key of knownShellSnapshotKeys) { - setState(key, EMPTY_SHELL_SNAPSHOT_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - markPending, - syncSnapshot, - applyEvent, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts b/packages/client-runtime/src/sourceControlDiscoveryState.test.ts deleted file mode 100644 index 9275ab64ee0..00000000000 --- a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { assert, beforeEach, it } from "vite-plus/test"; -import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; -import * as Option from "effect/Option"; -import { AtomRegistry } from "effect/unstable/reactivity"; - -import { - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, - createSourceControlDiscoveryManager, -} from "./sourceControlDiscoveryState.ts"; - -const EMPTY_RESULT: SourceControlDiscoveryResult = { - versionControlSystems: [], - sourceControlProviders: [], -}; - -const GITHUB_RESULT: SourceControlDiscoveryResult = { - versionControlSystems: [ - { - kind: "git", - label: "Git", - implemented: true, - status: "available", - version: Option.some("2.51.0"), - installHint: "Install Git.", - detail: Option.none(), - }, - ], - sourceControlProviders: [ - { - kind: "github", - label: "GitHub", - status: "available", - version: Option.some("2.85.0"), - installHint: "Install GitHub CLI.", - detail: Option.none(), - auth: { - status: "authenticated", - account: Option.some("octo"), - host: Option.some("github.com"), - detail: Option.none(), - }, - }, - ], -}; - -function unresolvedDiscovery() { - throw new Error("Discovery resolver was not initialized."); -} - -let registry = AtomRegistry.make(); - -const noop = () => undefined; - -beforeEach(() => { - registry.dispose(); - registry = AtomRegistry.make(); -}); - -function flushAsyncWork(): Promise { - return Promise.resolve().then(() => undefined); -} - -it("stores refreshed discovery data in an atom snapshot", async () => { - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => EMPTY_RESULT, - }), - }); - - assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE); - - const result = await manager.refresh({ key: "primary" }); - - assert.strictEqual(result, EMPTY_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); -}); - -it("deduplicates in-flight discovery refreshes by target key", async () => { - let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; - let calls = 0; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: () => { - calls += 1; - return new Promise((resolve) => { - resolveDiscovery = resolve; - }); - }, - }), - }); - - const first = manager.refresh({ key: "primary" }); - const second = manager.refresh({ key: "primary" }); - - assert.strictEqual(first, second); - assert.strictEqual(calls, 1); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); - - resolveDiscovery(EMPTY_RESULT); - await first; - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); -}); - -it("keeps the previous snapshot when refresh fails", async () => { - let shouldFail = false; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => { - if (shouldFail) { - throw new Error("probe failed"); - } - return EMPTY_RESULT; - }, - }), - }); - - await manager.refresh({ key: "primary" }); - shouldFail = true; - - const result = await manager.refresh({ key: "primary" }); - - assert.strictEqual(result, EMPTY_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: "probe failed", - isPending: false, - }); -}); - -it("invalidates a discovery target back to the initial snapshot", async () => { - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: async () => GITHUB_RESULT, - }), - }); - - await manager.refresh({ key: "primary" }); - manager.invalidate({ key: "primary" }); - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); -}); - -it("ignores an in-flight refresh after the target is invalidated", async () => { - let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => ({ - discoverSourceControl: () => - new Promise((resolve) => { - resolveDiscovery = resolve; - }), - }), - }); - - const refresh = manager.refresh({ key: "primary" }); - manager.invalidate({ key: "primary" }); - resolveDiscovery(GITHUB_RESULT); - - const result = await refresh; - - assert.strictEqual(result, GITHUB_RESULT); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: null, - error: null, - isPending: true, - }); -}); - -it("watches a discovery target with ref-counted client-change subscriptions", async () => { - let listener: () => void = noop; - let subscribeCalls = 0; - let unsubscribeCalls = 0; - let discoveryCalls = 0; - const client = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return EMPTY_RESULT; - }, - }; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => client, - subscribeClientChanges: (nextListener) => { - subscribeCalls += 1; - listener = nextListener; - return () => { - unsubscribeCalls += 1; - }; - }, - }); - - const firstUnwatch = manager.watch({ key: "primary" }); - const secondUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - - assert.strictEqual(subscribeCalls, 1); - assert.strictEqual(discoveryCalls, 1); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); - - listener(); - await flushAsyncWork(); - assert.strictEqual(discoveryCalls, 1); - - firstUnwatch(); - assert.strictEqual(unsubscribeCalls, 0); - - secondUnwatch(); - assert.strictEqual(unsubscribeCalls, 1); -}); - -it("reuses fresh watched discovery results on remount", async () => { - let discoveryCalls = 0; - const client = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return EMPTY_RESULT; - }, - }; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => client, - staleTimeMs: 60_000, - }); - - const firstUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - firstUnwatch(); - - const secondUnwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - secondUnwatch(); - - assert.strictEqual(discoveryCalls, 1); -}); - -it("refreshes a watched discovery target when the resolved client is replaced", async () => { - let listener: () => void = noop; - let activeResult = EMPTY_RESULT; - let discoveryCalls = 0; - const firstClient = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return activeResult; - }, - }; - const secondClient = { - discoverSourceControl: async () => { - discoveryCalls += 1; - return activeResult; - }, - }; - let activeClient = firstClient; - const manager = createSourceControlDiscoveryManager({ - getRegistry: () => registry, - getClient: () => activeClient, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return () => undefined; - }, - }); - - const unwatch = manager.watch({ key: "primary" }); - await flushAsyncWork(); - - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: EMPTY_RESULT, - error: null, - isPending: false, - }); - - activeClient = secondClient; - activeResult = GITHUB_RESULT; - listener(); - await flushAsyncWork(); - - assert.strictEqual(discoveryCalls, 2); - assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { - data: GITHUB_RESULT, - error: null, - isPending: false, - }); - - unwatch(); -}); diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.ts b/packages/client-runtime/src/sourceControlDiscoveryState.ts deleted file mode 100644 index 105b2baf445..00000000000 --- a/packages/client-runtime/src/sourceControlDiscoveryState.ts +++ /dev/null @@ -1,401 +0,0 @@ -import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -/* --- Types ---------------------------------------------------------- */ - -export interface SourceControlDiscoveryState { - readonly data: SourceControlDiscoveryResult | null; - readonly error: string | null; - readonly isPending: boolean; -} - -export interface SourceControlDiscoveryTarget { - readonly key: TKey | null; -} - -export interface SourceControlDiscoveryClient { - readonly discoverSourceControl: () => Promise; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -/* --- Constants ------------------------------------------------------ */ - -export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, -}); - -const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, -}); - -/* --- Atoms ---------------------------------------------------------- */ - -const knownSourceControlDiscoveryKeys = new Set(); - -export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => { - knownSourceControlDiscoveryKeys.add(key); - return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`source-control-discovery:${key}`), - ); -}); - -export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( - EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, -).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null")); - -/* --- Helpers -------------------------------------------------------- */ - -export function getSourceControlDiscoveryTargetKey( - target: SourceControlDiscoveryTarget, -): TKey | null { - const key = target.key; - return key && key.length > 0 ? key : null; -} - -/* --- Refresh manager ------------------------------------------------ */ - -export interface SourceControlDiscoveryManagerConfig { - /** - * Get the atom registry used to read/write source-control discovery snapshots. - */ - readonly getRegistry: () => AtomRegistry.AtomRegistry; - /** - * Resolve the runtime client for a discovery target key. - * - * Web currently uses a single `"primary"` target, but keeping this keyed - * lets mobile or future multi-environment clients provide separate discovery - * clients without changing the state primitive. - */ - readonly getClient: (key: TKey) => SourceControlDiscoveryClient | null; - /** - * Optional: subscribe to environment/client availability changes. - * - * When provided, `watch` refreshes as clients appear or are replaced - * instead of relying on React hooks to manually kick discovery. - */ - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly staleTimeMs?: number; - readonly idleTtlMs?: number; -} - -const NOOP: () => void = () => undefined; -const DEFAULT_STALE_TIME_MS = 30_000; -const DEFAULT_IDLE_TTL_MS = 5 * 60_000; - -export function createSourceControlDiscoveryManager( - config: SourceControlDiscoveryManagerConfig, -) { - const refreshInFlight = new Map< - string, - { - readonly client: SourceControlDiscoveryClient; - readonly promise: Promise; - } - >(); - const refreshVersions = new Map(); - const watched = new Map(); - const refreshTargets = new Map>(); - const staleTimeMs = config.staleTimeMs ?? DEFAULT_STALE_TIME_MS; - const idleTtlMs = config.idleTtlMs ?? DEFAULT_IDLE_TTL_MS; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? refresh(target) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: staleTimeMs, - revalidateOnMount: true, - }), - Atom.setIdleTTL(idleTtlMs), - Atom.withLabel(`source-control-discovery:watched-refresh:${targetKey}`), - ), - ); - - function getRefreshVersion(targetKey: string): number { - return refreshVersions.get(targetKey) ?? 0; - } - - function bumpRefreshVersion(targetKey: string): void { - refreshVersions.set(targetKey, getRefreshVersion(targetKey) + 1); - } - - /* -- Atom helpers -------------------------------------------------- */ - - function setState(targetKey: string, nextState: SourceControlDiscoveryState): void { - config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - const next: SourceControlDiscoveryState = - current.data === null - ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE - : { - data: current.data, - error: null, - isPending: true, - }; - - if ( - current.data === next.data && - current.error === next.error && - current.isPending === next.isPending - ) { - return; - } - - setState(targetKey, next); - } - - function setData(targetKey: string, data: SourceControlDiscoveryResult): void { - setState(targetKey, { - data, - error: null, - isPending: false, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - error: error instanceof Error ? error.message : "Failed to discover source control tools.", - isPending: false, - }); - } - - /* -- Public API ---------------------------------------------------- */ - - /** - * Trigger a one-shot source-control discovery RPC for a target. - * - * Calls are deduplicated while a refresh for the same target key is in - * flight. On failure, the previous successful snapshot is kept in `data` - * and the error message is stored separately so UI can keep rendering stale - * discovery results while showing the failure. - * - * @param target The logical runtime target to refresh. - * @param client Optional pre-resolved client, useful in tests. - */ - function refresh( - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): Promise { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return Promise.resolve(null); - } - refreshTargets.set(targetKey, target); - - const resolvedClient = client ?? config.getClient(targetKey); - if (!resolvedClient) { - const error = new Error("Source control discovery client is unavailable."); - setError(targetKey, error); - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) { - if (!client || existing.client === resolvedClient) { - return existing.promise; - } - - return existing.promise.then(() => refresh(target, resolvedClient)); - } - - markPending(targetKey); - const refreshVersion = getRefreshVersion(targetKey); - const promise = resolvedClient.discoverSourceControl().then( - (result) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setData(targetKey, result); - } - return result; - }, - (error: unknown) => { - if (getRefreshVersion(targetKey) === refreshVersion) { - setError(targetKey, error); - } - return getSnapshot(target).data; - }, - ); - let tracked: Promise; - tracked = promise.finally(() => { - if (refreshInFlight.get(targetKey)?.promise === tracked) { - refreshInFlight.delete(targetKey); - } - }); - refreshInFlight.set(targetKey, { - client: resolvedClient, - promise: tracked, - }); - return tracked; - } - - /** - * Reset discovery state for one target and ignore any currently in-flight - * refresh for that target. If no target is provided, every known target is - * invalidated. - */ - function invalidate(target?: SourceControlDiscoveryTarget): void { - if (!target) { - reset(); - return; - } - - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return; - } - - bumpRefreshVersion(targetKey); - refreshInFlight.delete(targetKey); - setState(targetKey, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); - } - - /** - * Read the current atom snapshot for `target`. - * - * Invalid targets return the inert empty state rather than creating a new - * family atom entry. - */ - function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; - } - - return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); - } - - /** - * Keep discovery warm for `target`. - * - * Multiple callers sharing a target key are ref-counted. With - * `subscribeClientChanges`, the manager refreshes whenever a client first - * appears or is replaced after reconnect. - */ - function watch( - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): () => void { - const targetKey = getSourceControlDiscoveryTargetKey(target); - if (targetKey === null) { - return NOOP; - } - refreshTargets.set(targetKey, target); - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - void refresh(target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: SourceControlDiscoveryClient | null = null; - - const sync = () => { - const resolved = config.getClient(targetKey); - if (!resolved) { - currentClient = null; - markPending(targetKey); - return; - } - - if (currentClient === resolved) { - return; - } - - const isClientReplacement = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, isClientReplacement ? resolved : undefined); - }; - - const unsubChanges = config.subscribeClientChanges(sync); - sync(); - teardown = unsubChanges; - } else { - if (!config.getClient(targetKey)) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function refreshWatchedTarget( - targetKey: string, - target: SourceControlDiscoveryTarget, - client?: SourceControlDiscoveryClient, - ): void { - refreshTargets.set(targetKey, target); - if (client) { - void refresh(target, client); - return; - } - - config.getRegistry().get(watchedRefreshAtom(targetKey)); - } - - /** - * Clear in-flight refresh tracking and reset every known discovery atom. - * Primarily used by tests and runtime teardown. - */ - function reset(): void { - const keys = new Set([...knownSourceControlDiscoveryKeys, ...refreshInFlight.keys()]); - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - refreshTargets.clear(); - refreshInFlight.clear(); - for (const key of keys) { - bumpRefreshVersion(key); - setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); - } - } - - return { - watch, - refresh, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts new file mode 100644 index 00000000000..29679d00ffe --- /dev/null +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -0,0 +1,15 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; + +import { + makeArchivedThreadsEnvironmentKey, + parseArchivedThreadsEnvironmentKey, +} from "./archivedThreads.ts"; + +it("round-trips environment keys in sorted order", () => { + const envA = EnvironmentId.make("env-a"); + const envB = EnvironmentId.make("env-b"); + const key = makeArchivedThreadsEnvironmentKey([envB, envA]); + + expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); +}); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts new file mode 100644 index 00000000000..9fbb19f632e --- /dev/null +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -0,0 +1,30 @@ +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import { pipe } from "effect/Function"; +import * as Order from "effect/Order"; + +export interface ArchivedSnapshotEntry { + readonly environmentId: EnvironmentId; + readonly snapshot: OrchestrationShellSnapshot; +} + +const ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR = "\u001f"; +const environmentIdOrder = Order.String as Order.Order; + +export function makeArchivedThreadsEnvironmentKey( + environmentIds: ReadonlyArray, +): string { + return pipe(environmentIds, Arr.sort(environmentIdOrder), (sortedEnvironmentIds) => + sortedEnvironmentIds.join(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), + ); +} + +export function parseArchivedThreadsEnvironmentKey(key: string): ReadonlyArray { + if (key.length === 0) { + return []; + } + return pipe( + key.split(ARCHIVED_THREADS_ENVIRONMENT_KEY_SEPARATOR), + Arr.map((environmentId) => EnvironmentId.make(environmentId)), + ); +} diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts new file mode 100644 index 00000000000..cf2808211bc --- /dev/null +++ b/packages/client-runtime/src/state/assets.ts @@ -0,0 +1,16 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createEnvironmentRpcMutation } from "./runtime.ts"; + +export function createAssetEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + createUrl: createEnvironmentRpcMutation(runtime, { + label: "environment-data:assets:create-url", + tag: WS_METHODS.assetsCreateUrl, + }), + }; +} diff --git a/packages/client-runtime/src/state/auth.test.ts b/packages/client-runtime/src/state/auth.test.ts new file mode 100644 index 00000000000..b31fe617912 --- /dev/null +++ b/packages/client-runtime/src/state/auth.test.ts @@ -0,0 +1,79 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; + +import { applyAuthAccessStreamEvent, EMPTY_AUTH_ACCESS_SNAPSHOT } from "./auth.ts"; + +describe("applyAuthAccessStreamEvent", () => { + it("accumulates rapid pairing-link and client updates into one snapshot", () => { + const pairingLink = { + id: "pairing-link", + credential: "credential", + scopes: ["orchestration:read"], + subject: "subject", + label: "Phone", + createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), + } as const; + const clientSession = { + sessionId: AuthSessionId.make("session-client"), + subject: "subject", + scopes: ["orchestration:read"], + method: "browser-session-cookie", + client: { + label: "Phone", + deviceType: "mobile", + }, + issuedAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-05-07T00:00:00.000Z"), + lastConnectedAt: null, + connected: true, + current: false, + } as const; + + const withPairingLink = applyAuthAccessStreamEvent(EMPTY_AUTH_ACCESS_SNAPSHOT, { + version: 1, + revision: 1, + type: "pairingLinkUpserted", + payload: pairingLink, + }); + const withClient = applyAuthAccessStreamEvent(withPairingLink, { + version: 1, + revision: 2, + type: "clientUpserted", + payload: clientSession, + }); + + expect(withClient).toEqual({ + pairingLinks: [pairingLink], + clientSessions: [clientSession], + }); + }); + + it("applies removals without disturbing unrelated access state", () => { + const snapshot = applyAuthAccessStreamEvent( + { + pairingLinks: [ + { + id: "pairing-link", + credential: "credential", + scopes: ["orchestration:read"], + subject: "subject", + label: "Phone", + createdAt: DateTime.makeUnsafe("2036-04-07T00:00:00.000Z"), + expiresAt: DateTime.makeUnsafe("2036-04-07T00:05:00.000Z"), + }, + ], + clientSessions: [], + }, + { + version: 1, + revision: 2, + type: "pairingLinkRemoved", + payload: { id: "pairing-link" }, + }, + ); + + expect(snapshot).toEqual(EMPTY_AUTH_ACCESS_SNAPSHOT); + }); +}); diff --git a/packages/client-runtime/src/state/auth.ts b/packages/client-runtime/src/state/auth.ts new file mode 100644 index 00000000000..074b89627af --- /dev/null +++ b/packages/client-runtime/src/state/auth.ts @@ -0,0 +1,90 @@ +import type { + AuthAccessSnapshot, + AuthAccessStreamEvent, + AuthAccessStreamSnapshotEvent, +} from "@t3tools/contracts"; +import { WS_METHODS } from "@t3tools/contracts"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe } from "../rpc/client.ts"; +import { createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; + +export const EMPTY_AUTH_ACCESS_SNAPSHOT: AuthAccessSnapshot = { + pairingLinks: [], + clientSessions: [], +}; + +function upsertByKey( + values: ReadonlyArray, + next: A, + key: (value: A) => string, +): ReadonlyArray { + const nextKey = key(next); + return [...values.filter((value) => key(value) !== nextKey), next]; +} + +export function applyAuthAccessStreamEvent( + current: AuthAccessSnapshot, + event: AuthAccessStreamEvent, +): AuthAccessSnapshot { + switch (event.type) { + case "snapshot": + return event.payload; + case "pairingLinkUpserted": + return { + ...current, + pairingLinks: upsertByKey(current.pairingLinks, event.payload, (value) => value.id), + }; + case "pairingLinkRemoved": + return { + ...current, + pairingLinks: current.pairingLinks.filter((value) => value.id !== event.payload.id), + }; + case "clientUpserted": + return { + ...current, + clientSessions: upsertByKey( + current.clientSessions, + event.payload, + (value) => value.sessionId, + ), + }; + case "clientRemoved": + return { + ...current, + clientSessions: current.clientSessions.filter( + (value) => value.sessionId !== event.payload.sessionId, + ), + }; + } +} + +export function projectAuthAccessSnapshot( + current: AuthAccessSnapshot, + event: AuthAccessStreamEvent, +): readonly [AuthAccessSnapshot, ReadonlyArray] { + const snapshot = applyAuthAccessStreamEvent(current, event); + const projected: AuthAccessStreamSnapshotEvent = { + version: 1, + revision: event.revision, + type: "snapshot", + payload: snapshot, + }; + return [snapshot, [projected]]; +} + +export function createAuthEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + accessChanges: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:server:auth-access-changes", + subscribe: (_input: null) => + subscribe(WS_METHODS.subscribeAuthAccess, {}).pipe( + Stream.mapAccum(() => EMPTY_AUTH_ACCESS_SNAPSHOT, projectAuthAccessSnapshot), + ), + }), + }; +} diff --git a/packages/client-runtime/src/state/checkpointDiff.ts b/packages/client-runtime/src/state/checkpointDiff.ts new file mode 100644 index 00000000000..455ceaf00d7 --- /dev/null +++ b/packages/client-runtime/src/state/checkpointDiff.ts @@ -0,0 +1,25 @@ +import type { + EnvironmentId, + OrchestrationGetFullThreadDiffResult, + OrchestrationGetTurnDiffResult, + ThreadId, +} from "@t3tools/contracts"; + +export type CheckpointDiffResult = + | OrchestrationGetTurnDiffResult + | OrchestrationGetFullThreadDiffResult; + +export interface CheckpointDiffState { + readonly data: CheckpointDiffResult | null; + readonly error: string | null; + readonly isPending: boolean; +} + +export interface CheckpointDiffTarget { + readonly environmentId: EnvironmentId | null; + readonly threadId: ThreadId | null; + readonly fromTurnCount: number | null; + readonly toTurnCount: number | null; + readonly ignoreWhitespace: boolean; + readonly cacheScope?: string | null; +} diff --git a/packages/client-runtime/src/state/cloud.ts b/packages/client-runtime/src/state/cloud.ts new file mode 100644 index 00000000000..3bc79daa8ce --- /dev/null +++ b/packages/client-runtime/src/state/cloud.ts @@ -0,0 +1,23 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcStreamMutation, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createCloudEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + relayClientStatus: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:cloud:relay-client-status", + tag: WS_METHODS.cloudGetRelayClientStatus, + }), + installRelayClient: createEnvironmentRpcStreamMutation(runtime, { + label: "environment-data:cloud:install-relay-client", + tag: WS_METHODS.cloudInstallRelayClient, + }), + }; +} diff --git a/packages/client-runtime/src/state/composerPathSearch.ts b/packages/client-runtime/src/state/composerPathSearch.ts new file mode 100644 index 00000000000..262c9f49b5f --- /dev/null +++ b/packages/client-runtime/src/state/composerPathSearch.ts @@ -0,0 +1,19 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface ComposerPathSearchEntry { + readonly path: string; + readonly kind: "file" | "directory"; + readonly parentPath?: string; +} + +export interface ComposerPathSearchState { + readonly entries: ReadonlyArray; + readonly isPending: boolean; + readonly error: string | null; +} + +export interface ComposerPathSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string | null; +} diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts new file mode 100644 index 00000000000..3d1915be77f --- /dev/null +++ b/packages/client-runtime/src/state/connections.ts @@ -0,0 +1,98 @@ +import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry, type EnvironmentRegistryService } from "../connection/registry.ts"; +import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; +import { AVAILABLE_CONNECTION_STATE } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export interface EnvironmentCatalogState { + readonly isReady: boolean; + readonly entries: ReadonlyMap; +} + +export const EMPTY_ENVIRONMENT_CATALOG_STATE: EnvironmentCatalogState = Object.freeze({ + isReady: false, + entries: new Map(), +}); + +export function createEnvironmentCatalogAtoms( + runtime: Atom.AtomRuntime, +) { + const catalogAtom = runtime.atom( + Stream.unwrap( + EnvironmentRegistry.pipe( + Effect.map((registry) => + SubscriptionRef.changes(registry.entries).pipe( + Stream.map((entries) => ({ + isReady: true, + entries, + })), + ), + ), + ), + ), + { initialValue: EMPTY_ENVIRONMENT_CATALOG_STATE }, + ); + + const catalogValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), + ).pipe(Atom.withLabel("environment-catalog-value")); + + const networkStatusAtom = runtime.atom( + Stream.unwrap( + EnvironmentRegistry.pipe( + Effect.map((registry) => SubscriptionRef.changes(registry.networkStatus)), + ), + ), + { initialValue: "unknown" as const }, + ); + + const networkStatusValueAtom = Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(networkStatusAtom)), () => "unknown" as const), + ).pipe(Atom.withLabel("environment-network-status-value")); + + const stateAtom = Atom.family((environmentId: EnvironmentIdType) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.state)), + ), + ), + ), + { initialValue: AVAILABLE_CONNECTION_STATE }, + ), + ); + + const register = runtime.fn((target: Parameters[0]) => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.register(target))), + ); + const remove = runtime.fn((environmentId: EnvironmentIdType) => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.remove(environmentId))), + ); + const removeRelayEnvironments = runtime.fn(() => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.removeRelayEnvironments())), + ); + const retryNow = runtime.fn((environmentId: EnvironmentIdType) => + EnvironmentRegistry.pipe(Effect.flatMap((registry) => registry.retryNow(environmentId))), + ); + + return { + catalogAtom, + catalogValueAtom, + networkStatusAtom, + networkStatusValueAtom, + stateAtom, + register, + remove, + removeRelayEnvironments, + retryNow, + }; +} diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts new file mode 100644 index 00000000000..a04c7253f99 --- /dev/null +++ b/packages/client-runtime/src/state/entities.test.ts @@ -0,0 +1,277 @@ +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationShellSnapshot, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentShellState } from "./shell.ts"; +import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; +import { createEnvironmentProjectAtoms } from "./projectEntities.ts"; +import { createEnvironmentSnapshotAtom } from "./snapshots.ts"; +import { createEnvironmentThreadDetailAtoms } from "./threadDetail.ts"; +import { createEnvironmentThreadShellAtoms } from "./threadShell.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const OTHER_PROJECT_ID = ProjectId.make("project-2"); +const THREAD_ID = ThreadId.make("thread-1"); +const OTHER_THREAD_ID = ThreadId.make("thread-2"); + +const THREAD_SHELL = { + id: THREAD_ID, + projectId: PROJECT_ID, + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + archivedAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, +} as const; + +const SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + updatedAt: "2026-06-01T00:00:00.000Z", + projects: [ + { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + { + id: OTHER_PROJECT_ID, + title: "Other project", + workspaceRoot: "/other-repo", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ], + threads: [ + THREAD_SHELL, + { + ...THREAD_SHELL, + id: OTHER_THREAD_ID, + projectId: OTHER_PROJECT_ID, + title: "Other thread", + }, + ], +}; + +function shellState(snapshot: OrchestrationShellSnapshot): EnvironmentShellState { + return { + snapshot: Option.some(snapshot), + status: "live", + error: Option.none(), + }; +} + +function makeHarness() { + const shellStateAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make(AsyncResult.success(shellState(SNAPSHOT))), + ); + const threadStateAtoms = Atom.family((_key: string) => + Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map([ + [ + ENVIRONMENT_ID, + { + target: new PrimaryConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Environment", + httpBaseUrl: "https://example.test", + wsBaseUrl: "wss://example.test", + }), + profile: Option.none(), + }, + ], + ]), + }); + const snapshotAtom = createEnvironmentSnapshotAtom(shellStateAtoms); + const projects = createEnvironmentProjectAtoms({ + catalogValueAtom, + snapshotAtom, + }); + const threadShells = createEnvironmentThreadShellAtoms({ + catalogValueAtom, + snapshotAtom, + }); + const threadDetails = createEnvironmentThreadDetailAtoms((environmentId, threadId) => + threadStateAtoms(`${environmentId}\u0000${threadId}`), + ); + + return { + registry: AtomRegistry.make(), + shellStateAtom: shellStateAtoms(ENVIRONMENT_ID), + threadStateAtom: (threadId: ThreadId) => threadStateAtoms(`${ENVIRONMENT_ID}\u0000${threadId}`), + projects, + threadShells, + threadDetails, + }; +} + +describe("environment entity projections", () => { + it("preserves untouched project and thread identities across unrelated shell updates", () => { + const harness = makeHarness(); + const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID); + const threadRefsAtom = harness.threadShells.environmentThreadRefsAtom(ENVIRONMENT_ID); + const projectsAtom = harness.projects.projectsAtom; + const projectAtom = harness.projects.projectAtom({ + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }); + const threadAtom = harness.threadShells.threadShellAtom({ + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }); + const projectRefs = harness.registry.get(projectRefsAtom); + const threadRefs = harness.registry.get(threadRefsAtom); + const projects = harness.registry.get(projectsAtom); + const project = harness.registry.get(projectAtom); + const thread = harness.registry.get(threadAtom); + + harness.registry.set( + harness.shellStateAtom, + AsyncResult.success( + shellState({ + ...SNAPSHOT, + snapshotSequence: 2, + threads: SNAPSHOT.threads.map((candidate) => + candidate.id === OTHER_THREAD_ID + ? { ...candidate, title: "Renamed other thread" } + : candidate, + ), + }), + ), + ); + + expect(harness.registry.get(projectRefsAtom)).toBe(projectRefs); + expect(harness.registry.get(threadRefsAtom)).toBe(threadRefs); + expect(harness.registry.get(projectsAtom)).toBe(projects); + expect(harness.registry.get(projectAtom)).toBe(project); + expect(harness.registry.get(threadAtom)).toBe(thread); + }); + + it("preserves project-scoped thread collections across unrelated project updates", () => { + const harness = makeHarness(); + const projectRef = { + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + }; + const refsByProjectAtom = + harness.threadShells.environmentThreadRefsByProjectAtom(ENVIRONMENT_ID); + const threadsAtom = harness.threadShells.threadShellsForProjectRefsAtom([projectRef]); + const refs = harness.registry.get(refsByProjectAtom).get(PROJECT_ID); + const threads = harness.registry.get(threadsAtom); + + expect(threads).toHaveLength(1); + expect(threads[0]?.id).toBe(THREAD_ID); + + harness.registry.set( + harness.shellStateAtom, + AsyncResult.success( + shellState({ + ...SNAPSHOT, + snapshotSequence: 2, + threads: SNAPSHOT.threads.map((thread) => + thread.id === OTHER_THREAD_ID ? { ...thread, title: "Updated elsewhere" } : thread, + ), + }), + ), + ); + + expect(harness.registry.get(refsByProjectAtom).get(PROJECT_ID)).toBe(refs); + expect(harness.registry.get(threadsAtom)).toBe(threads); + }); + + it("updates only the requested thread detail and preserves untouched field identities", () => { + const harness = makeHarness(); + const threadRef = { + environmentId: ENVIRONMENT_ID, + threadId: THREAD_ID, + }; + const otherThreadRef = { + environmentId: ENVIRONMENT_ID, + threadId: OTHER_THREAD_ID, + }; + const threadDetailAtom = harness.threadDetails.detailAtom(threadRef); + const messagesAtom = harness.threadDetails.messagesAtom(threadRef); + const activitiesAtom = harness.threadDetails.activitiesAtom(threadRef); + const statusAtom = harness.threadDetails.statusAtom(threadRef); + const otherThreadDetailAtom = harness.threadDetails.detailAtom(otherThreadRef); + const otherValue = harness.registry.get(otherThreadDetailAtom); + const detail = { + ...THREAD_SHELL, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + } satisfies OrchestrationThread; + + harness.registry.set( + harness.threadStateAtom(THREAD_ID), + AsyncResult.success({ + data: Option.some(detail), + status: "live", + error: Option.none(), + }), + ); + + const scopedDetail = harness.registry.get(threadDetailAtom); + const messages = harness.registry.get(messagesAtom); + const activities = harness.registry.get(activitiesAtom); + + expect(scopedDetail).toEqual({ ...detail, environmentId: ENVIRONMENT_ID }); + expect(harness.registry.get(statusAtom)).toBe("live"); + expect(harness.registry.get(otherThreadDetailAtom)).toBe(otherValue); + + harness.registry.set( + harness.threadStateAtom(THREAD_ID), + AsyncResult.success({ + data: Option.some({ + ...detail, + session: { + threadId: THREAD_ID, + status: "ready", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-06-01T00:01:00.000Z", + }, + }), + status: "live", + error: Option.none(), + }), + ); + + expect(harness.registry.get(messagesAtom)).toBe(messages); + expect(harness.registry.get(activitiesAtom)).toBe(activities); + }); +}); diff --git a/packages/client-runtime/src/state/entities.ts b/packages/client-runtime/src/state/entities.ts new file mode 100644 index 00000000000..4bcf16f7cfd --- /dev/null +++ b/packages/client-runtime/src/state/entities.ts @@ -0,0 +1,81 @@ +import { + EnvironmentId, + ProjectId, + ThreadId, + type ScopedProjectRef, + type ScopedThreadRef, +} from "@t3tools/contracts"; + +export function projectKey(ref: ScopedProjectRef): string { + return `${ref.environmentId}\u0000${ref.projectId}`; +} + +export function threadKey(ref: ScopedThreadRef): string { + return `${ref.environmentId}\u0000${ref.threadId}`; +} + +export function projectRefCollectionKey(refs: ReadonlyArray): string { + return JSON.stringify(refs.map((ref) => [ref.environmentId, ref.projectId])); +} + +export function parseProjectKey(key: string): ScopedProjectRef { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new Error("Invalid scoped project atom key."); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + projectId: ProjectId.make(key.slice(separator + 1)), + }; +} + +export function parseProjectRefCollectionKey(key: string): ReadonlyArray { + const entries = JSON.parse(key) as ReadonlyArray; + return entries.map(([environmentId, projectId]) => ({ + environmentId: EnvironmentId.make(environmentId), + projectId: ProjectId.make(projectId), + })); +} + +export function parseThreadKey(key: string): ScopedThreadRef { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new Error("Invalid scoped thread atom key."); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + threadId: ThreadId.make(key.slice(separator + 1)), + }; +} + +export function projectRefsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.projectId === right[index]?.projectId, + ) + ); +} + +export function threadRefsEqual( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (ref, index) => + ref.environmentId === right[index]?.environmentId && + ref.threadId === right[index]?.threadId, + ) + ); +} + +export function arrayElementsEqual(left: ReadonlyArray, right: ReadonlyArray): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts new file mode 100644 index 00000000000..c78b66cf316 --- /dev/null +++ b/packages/client-runtime/src/state/filesystem.ts @@ -0,0 +1,16 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createFilesystemEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + browse: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:filesystem:browse", + tag: WS_METHODS.filesystemBrowse, + }), + }; +} diff --git a/packages/client-runtime/src/state/git.ts b/packages/client-runtime/src/state/git.ts new file mode 100644 index 00000000000..1cf4ae41743 --- /dev/null +++ b/packages/client-runtime/src/state/git.ts @@ -0,0 +1,32 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcMutation, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcStreamMutation, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createGitEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + pullRequestResolution: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:git:resolve-pull-request", + tag: WS_METHODS.gitResolvePullRequest, + }), + runStackedAction: createEnvironmentRpcStreamMutation(runtime, { + label: "environment-data:git:run-stacked-action", + tag: WS_METHODS.gitRunStackedAction, + }), + resolvePullRequest: createEnvironmentRpcMutation(runtime, { + label: "environment-data:git:resolve-pull-request", + tag: WS_METHODS.gitResolvePullRequest, + }), + preparePullRequestThread: createEnvironmentRpcMutation(runtime, { + label: "environment-data:git:prepare-pull-request-thread", + tag: WS_METHODS.gitPreparePullRequestThread, + }), + }; +} diff --git a/packages/client-runtime/src/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts similarity index 100% rename from packages/client-runtime/src/gitActions.ts rename to packages/client-runtime/src/state/gitActions.ts diff --git a/packages/client-runtime/src/shellTypes.ts b/packages/client-runtime/src/state/models.ts similarity index 52% rename from packages/client-runtime/src/shellTypes.ts rename to packages/client-runtime/src/state/models.ts index 1d3a6e35de2..b601b59bfad 100644 --- a/packages/client-runtime/src/shellTypes.ts +++ b/packages/client-runtime/src/state/models.ts @@ -1,38 +1,53 @@ import type { EnvironmentId, + OrchestrationMessage, OrchestrationProjectShell, OrchestrationShellSnapshot, + OrchestrationThread, OrchestrationThreadShell, ThreadId, } from "@t3tools/contracts"; -export interface EnvironmentScopedProjectShell extends OrchestrationProjectShell { +export interface EnvironmentProject extends OrchestrationProjectShell { readonly environmentId: EnvironmentId; } -export interface EnvironmentScopedThreadShell extends OrchestrationThreadShell { +export interface EnvironmentThreadShell extends OrchestrationThreadShell { readonly environmentId: EnvironmentId; } -export function scopeProjectShell( +export type EnvironmentMessage = OrchestrationMessage; + +export interface EnvironmentThread extends OrchestrationThread { + readonly environmentId: EnvironmentId; +} + +export function scopeProject( environmentId: EnvironmentId, project: OrchestrationProjectShell, -): EnvironmentScopedProjectShell { +): EnvironmentProject { return { ...project, environmentId }; } export function scopeThreadShell( environmentId: EnvironmentId, thread: OrchestrationThreadShell, -): EnvironmentScopedThreadShell { +): EnvironmentThreadShell { + return { ...thread, environmentId }; +} + +export function scopeThread( + environmentId: EnvironmentId, + thread: OrchestrationThread, +): EnvironmentThread { return { ...thread, environmentId }; } -export function selectScopedThreadShell( +export function selectEnvironmentThreadShell( snapshot: OrchestrationShellSnapshot | null, environmentId: EnvironmentId, threadId: ThreadId, -): EnvironmentScopedThreadShell | null { +): EnvironmentThreadShell | null { const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; return thread ? scopeThreadShell(environmentId, thread) : null; } diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts new file mode 100644 index 00000000000..a3341631ce1 --- /dev/null +++ b/packages/client-runtime/src/state/orchestration.ts @@ -0,0 +1,28 @@ +import { ORCHESTRATION_WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcMutation, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createOrchestrationEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + turnDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:turn-diff", + tag: ORCHESTRATION_WS_METHODS.getTurnDiff, + }), + fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:full-thread-diff", + tag: ORCHESTRATION_WS_METHODS.getFullThreadDiff, + }), + archivedShellSnapshot: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:archived-shell-snapshot", + tag: ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, + }), + replayEvents: createEnvironmentRpcMutation(runtime, { + label: "environment-data:orchestration:replay-events", + tag: ORCHESTRATION_WS_METHODS.replayEvents, + }), + }; +} diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts new file mode 100644 index 00000000000..1321ece93f8 --- /dev/null +++ b/packages/client-runtime/src/state/presentation.ts @@ -0,0 +1,69 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { AVAILABLE_CONNECTION_STATE, type SupervisorConnectionState } from "../connection/model.ts"; +import { + presentEnvironmentConnection, + type EnvironmentPresentation, +} from "../connection/presentation.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; + +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +export function createEnvironmentPresentationAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly stateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; + readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + const presentationAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const entry = get(input.catalogValueAtom).entries.get(environmentId); + if (entry === undefined) { + return null; + } + const state = Option.getOrElse( + AsyncResult.value(get(input.stateAtom(environmentId))), + () => AVAILABLE_CONNECTION_STATE, + ); + return { + entry, + connection: presentEnvironmentConnection(state), + serverConfig: get(input.configValueAtom(environmentId)), + } satisfies EnvironmentPresentation; + }).pipe(Atom.withLabel(`environment-presentation:${environmentId}`)), + ); + + let previous: ReadonlyMap = new Map(); + const presentationsAtom = Atom.make((get) => { + const next = new Map(); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const presentation = get(presentationAtom(environmentId)); + if (presentation !== null) { + next.set(environmentId, presentation); + } + } + if (mapsEqual(previous, next)) { + return previous; + } + previous = next; + return previous; + }).pipe(Atom.withLabel("environment-presentations")); + + return { + presentationAtom, + presentationsAtom, + }; +} diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts new file mode 100644 index 00000000000..36b806f1495 --- /dev/null +++ b/packages/client-runtime/src/state/preview.ts @@ -0,0 +1,65 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createEnvironmentRpcMutation, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; + +export function createPreviewEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + list: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:preview:list", + tag: WS_METHODS.previewList, + staleTimeMs: 5_000, + }), + events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:events", + tag: WS_METHODS.subscribePreviewEvents, + }), + discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:discovered-servers", + tag: WS_METHODS.subscribeDiscoveredLocalServers, + }), + automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:automation-requests", + tag: WS_METHODS.previewAutomationConnect, + }), + open: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:open", + tag: WS_METHODS.previewOpen, + }), + navigate: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:navigate", + tag: WS_METHODS.previewNavigate, + }), + refresh: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:refresh", + tag: WS_METHODS.previewRefresh, + }), + close: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:close", + tag: WS_METHODS.previewClose, + }), + reportStatus: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:report-status", + tag: WS_METHODS.previewReportStatus, + }), + respondToAutomation: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:automation-respond", + tag: WS_METHODS.previewAutomationRespond, + }), + reportAutomationOwner: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:automation-report-owner", + tag: WS_METHODS.previewAutomationReportOwner, + }), + clearAutomationOwner: createEnvironmentRpcMutation(runtime, { + label: "environment-data:preview:automation-clear-owner", + tag: WS_METHODS.previewAutomationClearOwner, + }), + }; +} diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts new file mode 100644 index 00000000000..761af63a99a --- /dev/null +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -0,0 +1,52 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentMutation, + createEnvironmentRpcMutation, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; +import { + type CreateProjectInput, + type DeleteProjectInput, + type UpdateProjectInput, + createProject, + deleteProject, + updateProject, +} from "../operations/commands.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export type { + CreateProjectInput, + DeleteProjectInput, + UpdateProjectInput, +} from "../operations/commands.ts"; + +export function createProjectEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:projects:search-entries", + tag: WS_METHODS.projectsSearchEntries, + staleTimeMs: 15_000, + }), + create: createEnvironmentMutation(runtime, { + label: "environment-data:commands:project:create", + execute: (input: CreateProjectInput) => createProject(input), + }), + update: createEnvironmentMutation(runtime, { + label: "environment-data:commands:project:update", + execute: (input: UpdateProjectInput) => updateProject(input), + }), + delete: createEnvironmentMutation(runtime, { + label: "environment-data:commands:project:delete", + execute: (input: DeleteProjectInput) => deleteProject(input), + }), + writeFile: createEnvironmentRpcMutation(runtime, { + label: "environment-data:projects:write-file", + tag: WS_METHODS.projectsWriteFile, + }), + }; +} diff --git a/packages/client-runtime/src/state/projectEntities.ts b/packages/client-runtime/src/state/projectEntities.ts new file mode 100644 index 00000000000..4d51b4d427e --- /dev/null +++ b/packages/client-runtime/src/state/projectEntities.ts @@ -0,0 +1,105 @@ +import type { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationShellSnapshot, + ProjectId, + ScopedProjectRef, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentProject } from "./models.ts"; +import { scopeProject } from "./models.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts"; + +const EMPTY_PROJECTS: ReadonlyArray = Object.freeze([]); +const EMPTY_PROJECT_INDEX: ReadonlyMap = new Map(); + +export function createEnvironmentProjectAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly snapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(input.snapshotAtom(environmentId))?.projects ?? EMPTY_PROJECTS, + ).pipe(Atom.withLabel(`environment-projects:${environmentId}`)), + ); + + const environmentProjectIndexAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyMap => { + const projects = get(environmentProjectsAtom(environmentId)); + if (projects.length === 0) { + return EMPTY_PROJECT_INDEX; + } + return new Map(projects.map((project) => [project.id, project] as const)); + }).pipe(Atom.withLabel(`environment-project-index:${environmentId}`)), + ); + + const environmentProjectRefsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(environmentProjectsAtom(environmentId)).map((project) => ({ + environmentId, + projectId: project.id, + })); + if (projectRefsEqual(previous, next)) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-project-refs:${environmentId}`)); + }); + + const projectAtomFamily = Atom.family((key: string) => { + const ref = parseProjectKey(key); + let previousSource: OrchestrationProjectShell | null = null; + let previousValue: EnvironmentProject | null = null; + return Atom.make((get) => { + const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null; + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeProject(ref.environmentId, source); + return previousValue; + }).pipe(Atom.withLabel(`environment-project:${key}`)); + }); + + let previousProjectRefs: ReadonlyArray = []; + const projectRefsAtom = Atom.make((get) => { + const refs: ScopedProjectRef[] = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + refs.push(...get(environmentProjectRefsAtom(environmentId))); + } + if (projectRefsEqual(previousProjectRefs, refs)) { + return previousProjectRefs; + } + previousProjectRefs = refs; + return refs; + }).pipe(Atom.withLabel("environment-project-refs")); + + let previousProjects: ReadonlyArray = []; + const projectsAtom = Atom.make((get) => { + const next = get(projectRefsAtom).flatMap((ref) => { + const project = get(projectAtomFamily(projectKey(ref))); + return project === null ? [] : [project]; + }); + if (arrayElementsEqual(previousProjects, next)) { + return previousProjects; + } + previousProjects = next; + return previousProjects; + }).pipe(Atom.withLabel("environment-project-list")); + + return { + environmentProjectsAtom, + environmentProjectIndexAtom, + environmentProjectRefsAtom, + projectRefsAtom, + projectsAtom, + projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)), + }; +} diff --git a/packages/client-runtime/src/projectPaths.ts b/packages/client-runtime/src/state/projects.ts similarity index 98% rename from packages/client-runtime/src/projectPaths.ts rename to packages/client-runtime/src/state/projects.ts index a4d2c7e19ee..82a43350650 100644 --- a/packages/client-runtime/src/projectPaths.ts +++ b/packages/client-runtime/src/state/projects.ts @@ -5,9 +5,9 @@ import { isWindowsDrivePath, } from "@t3tools/shared/path"; -function isWindowsPlatform(platform: string): boolean { +const isWindowsPlatform = (platform: string): boolean => { return /^win(dows)?/i.test(platform); -} +}; function isRootPath(value: string): boolean { return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); @@ -219,3 +219,6 @@ export function getBrowseParentPath(currentPath: string): string | null { export function canNavigateUp(currentPath: string): boolean { return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null; } + +export * from "./projectCommands.ts"; +export * from "./projectEntities.ts"; diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts new file mode 100644 index 00000000000..57210eba304 --- /dev/null +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -0,0 +1,38 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { + EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + RelayEnvironmentDiscovery, +} from "../relay/discovery.ts"; + +export function createRelayEnvironmentDiscoveryAtoms( + runtime: Atom.AtomRuntime, +) { + const stateAtom = runtime.atom( + Stream.unwrap( + RelayEnvironmentDiscovery.pipe( + Effect.map((discovery) => SubscriptionRef.changes(discovery.state)), + ), + ), + { initialValue: EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, + ); + const stateValueAtom = Atom.make((get) => + Option.getOrElse( + AsyncResult.value(get(stateAtom)), + () => EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + ), + ).pipe(Atom.withLabel("relay-environment-discovery-value")); + const refresh = runtime.fn(() => + RelayEnvironmentDiscovery.pipe(Effect.flatMap((discovery) => discovery.refresh)), + ); + + return { + stateAtom, + stateValueAtom, + refresh, + }; +} diff --git a/packages/client-runtime/src/state/review.ts b/packages/client-runtime/src/state/review.ts new file mode 100644 index 00000000000..0d78d6edd9f --- /dev/null +++ b/packages/client-runtime/src/state/review.ts @@ -0,0 +1,17 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createReviewEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:review:diff-preview", + tag: WS_METHODS.reviewGetDiffPreview, + staleTimeMs: 5_000, + }), + }; +} diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts new file mode 100644 index 00000000000..295c33547a0 --- /dev/null +++ b/packages/client-runtime/src/state/runtime.ts @@ -0,0 +1,275 @@ +import { EnvironmentId, type EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; +import { + type EnvironmentRpcInput, + type EnvironmentRpcStreamFailure, + type EnvironmentRpcStreamValue, + type EnvironmentStreamCommandRpcTag, + type EnvironmentSubscriptionRpcTag, + type EnvironmentUnaryRpcTag, + request, + runStream, + subscribe, +} from "../rpc/client.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; + +interface EnvironmentAtomOptions { + readonly label: string; + readonly execute: (input: Input) => Effect.Effect; +} + +interface EnvironmentQueryAtomOptions extends EnvironmentAtomOptions< + Input, + A, + E, + R +> { + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; +} + +interface EnvironmentSubscriptionAtomOptions { + readonly label: string; + readonly subscribe: (input: Input) => Stream.Stream; + readonly idleTtlMs?: number; +} + +function environmentRpcKey(target: { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +}): string { + return JSON.stringify([target.environmentId, target.input]); +} + +function parseEnvironmentRpcKey(key: string): { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +} { + const decoded = JSON.parse(key) as [EnvironmentIdType, Input]; + return { + environmentId: EnvironmentId.make(decoded[0]), + input: decoded[1], + }; +} + +export function runInEnvironment( + environmentId: EnvironmentIdType, + effect: Effect.Effect, +): Effect.Effect< + A, + E | EnvironmentNotRegisteredError, + EnvironmentRegistry | Exclude +> { + return EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.run(environmentId, effect)), + ); +} + +export function runStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream< + A, + E | EnvironmentNotRegisteredError, + EnvironmentRegistry | Exclude +> { + return Stream.unwrap( + EnvironmentRegistry.pipe(Effect.map((registry) => registry.runStream(environmentId, stream))), + ); +} + +export function followStreamInEnvironment( + environmentId: EnvironmentIdType, + stream: Stream.Stream, +): Stream.Stream> { + return Stream.unwrap( + EnvironmentRegistry.pipe( + Effect.map((registry) => registry.followStream(environmentId, stream)), + ), + ); +} + +function createEnvironmentQueryAtomFamily( + runtime: Atom.AtomRuntime, + options: EnvironmentQueryAtomOptions, +): (target: { + readonly environmentId: EnvironmentIdType; + readonly input: Input; +}) => Atom.Atom> { + const rpcGenerationAtom = Atom.family((environmentId: EnvironmentIdType) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.state).pipe( + Stream.filterMap((state) => + state.phase === "connected" ? Result.succeed(state.generation) : Result.failVoid, + ), + Stream.changes, + Stream.map((generation) => generation), + ), + ), + ), + ), + ), + { initialValue: null }, + ), + ); + const family = Atom.family((key: string) => { + const target = parseEnvironmentRpcKey(key); + return runtime + .atom((get) => { + const generation = Option.getOrNull( + AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), + ); + if (generation === null) { + return Effect.never; + } + return runInEnvironment(target.environmentId, options.execute(target.input)); + }) + .pipe( + Atom.swr({ + staleTime: options.staleTimeMs ?? 30_000, + revalidateOnMount: true, + }), + Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000), + Atom.withLabel(`${options.label}:${key}`), + ); + }); + return (target) => family(environmentRpcKey(target)); +} + +export function createEnvironmentSubscriptionAtomFamily( + runtime: Atom.AtomRuntime, + options: EnvironmentSubscriptionAtomOptions, +) { + const family = Atom.family((key: string) => { + const target = parseEnvironmentRpcKey(key); + return runtime + .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input))) + .pipe( + Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000), + Atom.withLabel(`${options.label}:${key}`), + ); + }); + return (target: { readonly environmentId: EnvironmentIdType; readonly input: Input }) => + family(environmentRpcKey(target)); +} + +export function createEnvironmentMutation( + runtime: Atom.AtomRuntime, + options: EnvironmentAtomOptions, +) { + return runtime + .fn<{ readonly environmentId: EnvironmentIdType; readonly input: Input }>()((target) => + runInEnvironment(target.environmentId, options.execute(target.input)), + ) + .pipe(Atom.withLabel(options.label)); +} + +function createEnvironmentStreamMutation( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly execute: (input: Input) => Stream.Stream; + }, +) { + return runtime + .fn<{ readonly environmentId: EnvironmentIdType; readonly input: Input }>()< + E | EnvironmentNotRegisteredError, + A + >((target) => + runStreamInEnvironment(target.environmentId, options.execute(target.input)).pipe( + Stream.withSpan(options.label), + ), + ) + .pipe(Atom.withLabel(options.label)); +} + +export function createEnvironmentRpcQueryAtomFamily( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly staleTimeMs?: number; + readonly idleTtlMs?: number; + }, +) { + return createEnvironmentQueryAtomFamily(runtime, { + label: options.label, + ...(options.staleTimeMs === undefined ? {} : { staleTimeMs: options.staleTimeMs }), + ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), + execute: (input: EnvironmentRpcInput) => request(options.tag, input), + }); +} + +export function createEnvironmentRpcSubscriptionAtomFamily< + R, + ER, + TTag extends EnvironmentSubscriptionRpcTag, + B = EnvironmentRpcStreamValue, +>( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + readonly idleTtlMs?: number; + readonly transform?: ( + stream: Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor | R + >, + ) => Stream.Stream, EnvironmentSupervisor | R>; + }, +) { + return createEnvironmentSubscriptionAtomFamily(runtime, { + label: options.label, + ...(options.idleTtlMs === undefined ? {} : { idleTtlMs: options.idleTtlMs }), + subscribe: (input: EnvironmentRpcInput) => { + const stream = subscribe(options.tag, input); + return options.transform === undefined + ? (stream as Stream.Stream, EnvironmentSupervisor | R>) + : options.transform(stream); + }, + }); +} + +export function createEnvironmentRpcMutation( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + }, +) { + return createEnvironmentMutation(runtime, { + label: options.label, + execute: (input: EnvironmentRpcInput) => request(options.tag, input), + }); +} + +export function createEnvironmentRpcStreamMutation< + R, + ER, + TTag extends EnvironmentStreamCommandRpcTag, +>( + runtime: Atom.AtomRuntime, + options: { + readonly label: string; + readonly tag: TTag; + }, +) { + return createEnvironmentStreamMutation(runtime, { + label: options.label, + execute: (input: EnvironmentRpcInput) => runStream(options.tag, input), + }); +} diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts new file mode 100644 index 00000000000..4b9564e031c --- /dev/null +++ b/packages/client-runtime/src/state/server.test.ts @@ -0,0 +1,54 @@ +import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; + +const CONFIG = { + availableEditors: [], + issues: [], + keybindings: {}, + keybindingsConfigPath: null, + observability: null, + providers: [], + settings: {}, +} as unknown as ServerConfig; + +describe("server state projection", () => { + it("applies every config category to the projected snapshot", () => { + const snapshot = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: CONFIG, + }); + const settings = { ...CONFIG.settings }; + const projected = applyServerConfigProjection(snapshot, { + version: 1, + type: "settingsUpdated", + payload: { settings }, + }); + + const result = Option.getOrThrow(projected); + expect(result.config.settings).toBe(settings); + expect(result.latestEvent.type).toBe("settingsUpdated"); + }); + + it("retains welcome when a ready event follows in the same stream chunk", () => { + const welcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/repo", + projectName: "repo", + } as ServerLifecycleWelcomePayload; + const [afterWelcome] = projectServerWelcome(Option.none(), { + type: "welcome", + payload: welcome, + }); + const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + type: "ready", + payload: {}, + }); + + expect(Option.getOrThrow(afterReady)).toBe(welcome); + expect(emitted).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts new file mode 100644 index 00000000000..545f3df7622 --- /dev/null +++ b/packages/client-runtime/src/state/server.ts @@ -0,0 +1,149 @@ +import { + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleWelcomePayload, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcMutation, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export interface ServerConfigProjection { + readonly config: ServerConfig; + readonly latestEvent: ServerConfigStreamEvent; +} + +export function applyServerConfigProjection( + current: Option.Option, + event: ServerConfigStreamEvent, +): Option.Option { + switch (event.type) { + case "snapshot": + return Option.some({ + config: event.config, + latestEvent: event, + }); + case "keybindingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + keybindings: event.payload.keybindings, + issues: event.payload.issues, + }, + latestEvent: event, + })); + case "providerStatuses": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + providers: event.payload.providers, + }, + latestEvent: event, + })); + case "settingsUpdated": + return Option.map(current, (projection) => ({ + config: { + ...projection.config, + settings: event.payload.settings, + }, + latestEvent: event, + })); + } +} + +export function projectServerConfig( + current: Option.Option, + event: ServerConfigStreamEvent, +): readonly [Option.Option, ReadonlyArray] { + const next = applyServerConfigProjection(current, event); + return [next, Option.toArray(next)]; +} + +export function projectServerWelcome( + current: Option.Option, + event: { + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }, +): readonly [ + Option.Option, + ReadonlyArray, +] { + if (event.type !== "welcome") { + return [current, []]; + } + const welcome = event.payload as ServerLifecycleWelcomePayload; + return [Option.some(welcome), [welcome]]; +} + +export function createServerEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + config: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:config", + tag: WS_METHODS.serverGetConfig, + }), + settings: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:settings", + tag: WS_METHODS.serverGetSettings, + }), + traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:trace-diagnostics", + tag: WS_METHODS.serverGetTraceDiagnostics, + }), + processDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:process-diagnostics", + tag: WS_METHODS.serverGetProcessDiagnostics, + }), + processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:process-resource-history", + tag: WS_METHODS.serverGetProcessResourceHistory, + }), + configProjection: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:config-projection", + tag: WS_METHODS.subscribeServerConfig, + transform: (stream) => + stream.pipe(Stream.mapAccum(Option.none, projectServerConfig)), + }), + welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:welcome", + tag: WS_METHODS.subscribeServerLifecycle, + transform: (stream) => + stream.pipe( + Stream.mapAccum(Option.none, projectServerWelcome), + ), + }), + refreshProviders: createEnvironmentRpcMutation(runtime, { + label: "environment-data:server:refresh-providers", + tag: WS_METHODS.serverRefreshProviders, + }), + updateProvider: createEnvironmentRpcMutation(runtime, { + label: "environment-data:server:update-provider", + tag: WS_METHODS.serverUpdateProvider, + }), + upsertKeybinding: createEnvironmentRpcMutation(runtime, { + label: "environment-data:server:upsert-keybinding", + tag: WS_METHODS.serverUpsertKeybinding, + }), + removeKeybinding: createEnvironmentRpcMutation(runtime, { + label: "environment-data:server:remove-keybinding", + tag: WS_METHODS.serverRemoveKeybinding, + }), + updateSettings: createEnvironmentRpcMutation(runtime, { + label: "environment-data:server:update-settings", + tag: WS_METHODS.serverUpdateSettings, + }), + signalProcess: createEnvironmentRpcMutation(runtime, { + label: "environment-data:server:signal-process", + tag: WS_METHODS.serverSignalProcess, + }), + }; +} diff --git a/packages/client-runtime/src/state/session.test.ts b/packages/client-runtime/src/state/session.test.ts new file mode 100644 index 00000000000..fe1dcdbe3f2 --- /dev/null +++ b/packages/client-runtime/src/state/session.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { initialConfigOption } from "./session.ts"; + +class TestConfigError extends Schema.TaggedErrorClass()("TestConfigError", { + message: Schema.String, +}) {} + +describe("environment session state", () => { + it.effect("turns an initial config failure into an empty value", () => + Effect.gen(function* () { + const result = yield* initialConfigOption( + Effect.fail(new TestConfigError({ message: "temporary failure" })), + ); + expect(Option.isNone(result)).toBe(true); + }), + ); +}); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts new file mode 100644 index 00000000000..97a637a9c5a --- /dev/null +++ b/packages/client-runtime/src/state/session.ts @@ -0,0 +1,88 @@ +import type { EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import type { PreparedConnection } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export function initialConfigOption( + initialConfig: Effect.Effect, +): Effect.Effect> { + return initialConfig.pipe( + Effect.map(Option.some), + Effect.catch((error) => + Effect.logWarning("Could not load the initial environment configuration.", { + error, + }).pipe(Effect.as(Option.none())), + ), + ); +} + +export function createEnvironmentSessionAtoms( + runtime: Atom.AtomRuntime, +) { + const configAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.mapEffect( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (session) => initialConfigOption(session.initialConfig), + }), + ), + ), + ), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const configValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ServerConfig | null => + Option.getOrNull( + Option.getOrElse(AsyncResult.value(get(configAtom(environmentId))), () => Option.none()), + ), + ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)), + ); + + const preparedConnectionAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => SubscriptionRef.changes(supervisor.prepared)), + ), + ), + ), + { initialValue: Option.none() }, + ), + ); + + const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => + Option.none(), + ), + ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), + ); + + return { + configAtom, + configValueAtom, + preparedConnectionAtom, + preparedConnectionValueAtom, + }; +} diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts new file mode 100644 index 00000000000..fc39542e6b0 --- /dev/null +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -0,0 +1,123 @@ +import { + EnvironmentId, + ORCHESTRATION_WS_METHODS, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { makeEnvironmentShellState } from "./shell.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-06-06T00:00:00.000Z", +}; + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +describe("environment shell synchronization", () => { + it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const activeSession = yield* SubscriptionRef.make>( + Option.some(session(client)), + ); + const supervisor = EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: activeSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisorService); + const cache = EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + clear: () => Effect.void, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentCacheStore, cache), + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + }); + yield* Queue.offer(events, { + kind: "snapshot", + snapshot: LIVE_SHELL_SNAPSHOT, + }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((state) => state.status === "live"), + Stream.runHead, + ); + + yield* SubscriptionRef.set(supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + + const state = yield* SubscriptionRef.get(shellState); + expect(state.status).toBe("live"); + expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT); + }), + ); +}); diff --git a/packages/client-runtime/src/state/shell.test.ts b/packages/client-runtime/src/state/shell.test.ts new file mode 100644 index 00000000000..fcde2ad7d80 --- /dev/null +++ b/packages/client-runtime/src/state/shell.test.ts @@ -0,0 +1,130 @@ +import type { ServerConfig } from "@t3tools/contracts"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { PrimaryConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentShellState } from "./shell.ts"; +import { createEnvironmentServerConfigsAtom, createEnvironmentShellSummaryAtom } from "./shell.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2"); + +function environmentEntry(environmentId: EnvironmentId, label: string) { + return { + target: new PrimaryConnectionTarget({ + environmentId, + label, + httpBaseUrl: `https://${environmentId}.example.test`, + wsBaseUrl: `wss://${environmentId}.example.test`, + }), + profile: Option.none(), + }; +} + +function shellState(input: { + readonly status: EnvironmentShellState["status"]; + readonly updatedAt?: string; + readonly error?: string; + readonly snapshotSequence?: number; +}): EnvironmentShellState { + return { + snapshot: + input.updatedAt === undefined + ? Option.none() + : Option.some({ + snapshotSequence: input.snapshotSequence ?? 1, + updatedAt: input.updatedAt, + projects: [], + threads: [], + }), + status: input.status, + error: input.error === undefined ? Option.none() : Option.some(input.error), + }; +} + +function makeHarness() { + const shellStateAtoms = Atom.family((environmentId: EnvironmentId) => + Atom.make( + environmentId === ENVIRONMENT_ID + ? shellState({ + status: "cached", + updatedAt: "2026-06-01T00:00:00.000Z", + }) + : shellState({ + status: "synchronizing", + updatedAt: "2026-06-02T00:00:00.000Z", + error: "Retrying.", + }), + ), + ); + const configAtoms = Atom.family((_environmentId: EnvironmentId) => + Atom.make(null), + ); + const catalogValueAtom = Atom.make({ + isReady: true, + entries: new Map([ + [ENVIRONMENT_ID, environmentEntry(ENVIRONMENT_ID, "Environment")], + [OTHER_ENVIRONMENT_ID, environmentEntry(OTHER_ENVIRONMENT_ID, "Other environment")], + ]), + }); + const summaryAtom = createEnvironmentShellSummaryAtom({ + catalogValueAtom, + shellStateValueAtom: shellStateAtoms, + }); + const serverConfigsAtom = createEnvironmentServerConfigsAtom({ + catalogValueAtom, + configValueAtom: configAtoms, + }); + + return { + registry: AtomRegistry.make(), + shellStateAtom: shellStateAtoms, + configAtom: configAtoms, + summaryAtom, + serverConfigsAtom, + }; +} + +describe("environment shell projections", () => { + it("summarizes shell state and preserves identity when only irrelevant snapshot data changes", () => { + const harness = makeHarness(); + const summary = harness.registry.get(harness.summaryAtom); + + expect(summary).toEqual({ + hasSnapshot: true, + hasSynchronizingShell: true, + hasCachedShell: true, + hasLiveShell: false, + firstError: "Retrying.", + latestSnapshotUpdatedAt: "2026-06-02T00:00:00.000Z", + }); + + harness.registry.set( + harness.shellStateAtom(ENVIRONMENT_ID), + shellState({ + status: "cached", + updatedAt: "2026-06-01T00:00:00.000Z", + snapshotSequence: 2, + }), + ); + + expect(harness.registry.get(harness.summaryAtom)).toBe(summary); + }); + + it("preserves server-config map identity until a config reference changes", () => { + const harness = makeHarness(); + const empty = harness.registry.get(harness.serverConfigsAtom); + const config = { cwd: "/repo" } as ServerConfig; + + harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); + const withConfig = harness.registry.get(harness.serverConfigsAtom); + + expect(withConfig).not.toBe(empty); + expect(withConfig.get(ENVIRONMENT_ID)).toBe(config); + + harness.registry.set(harness.configAtom(ENVIRONMENT_ID), config); + expect(harness.registry.get(harness.serverConfigsAtom)).toBe(withConfig); + }); +}); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts new file mode 100644 index 00000000000..5ebf9fe5f15 --- /dev/null +++ b/packages/client-runtime/src/state/shell.ts @@ -0,0 +1,300 @@ +import { + ORCHESTRATION_WS_METHODS, + type EnvironmentId, + type OrchestrationShellSnapshot, + type OrchestrationShellStreamItem, + type ServerConfig, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { connectionProjectionPhase } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { subscribe } from "../rpc/client.ts"; +import { applyShellStreamEvent } from "./shellReducer.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export type EnvironmentShellStatus = "empty" | "cached" | "synchronizing" | "live"; + +export interface EnvironmentShellState { + readonly snapshot: Option.Option; + readonly status: EnvironmentShellStatus; + readonly error: Option.Option; +} + +const EMPTY_SHELL_STATE: EnvironmentShellState = { + snapshot: Option.none(), + status: "empty", + error: Option.none(), +}; + +function shellStatusForSnapshot( + snapshot: Option.Option, +): EnvironmentShellStatus { + return Option.isSome(snapshot) ? "cached" : "empty"; +} + +function formatShellError(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Could not synchronize environment data."; +} + +export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const cachedSnapshot = yield* cache.loadShell(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached environment shell.").pipe( + Effect.annotateLogs({ + environmentId, + error: error.message, + }), + Effect.as(Option.none()), + ), + ), + ); + const state = yield* SubscriptionRef.make({ + snapshot: cachedSnapshot, + status: shellStatusForSnapshot(cachedSnapshot), + error: Option.none(), + }); + + const setDisconnected = SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + })); + const setSynchronizing = SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + const setReady = SubscriptionRef.update(state, (current) => + current.status === "live" + ? current + : { + ...current, + status: "synchronizing" as const, + error: Option.none(), + }, + ); + const setStreamError = (error: unknown) => + SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + error: Option.some(formatShellError(error)), + })); + + const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( + item: OrchestrationShellStreamItem, + ) { + const current = yield* SubscriptionRef.get(state); + const nextSnapshot = + item.kind === "snapshot" + ? item.snapshot + : Option.match(current.snapshot, { + onNone: () => null, + onSome: (snapshot) => + item.sequence > snapshot.snapshotSequence + ? applyShellStreamEvent(snapshot, item) + : snapshot, + }); + if (nextSnapshot === null) { + return; + } + + yield* cache.saveShell(environmentId, nextSnapshot).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist environment shell cache.").pipe( + Effect.annotateLogs({ + environmentId, + error: error.message, + }), + ), + ), + ); + yield* SubscriptionRef.set(state, { + snapshot: Option.some(nextSnapshot), + status: "live", + error: Option.none(), + }); + }); + + yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, {}).pipe( + Stream.runForEach(applyItem), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Effect.interrupt : setStreamError(Cause.squash(cause)), + ), + Effect.forkScoped, + ); + yield* SubscriptionRef.changes(supervisor.state).pipe( + Stream.runForEach((connectionState) => { + switch (connectionProjectionPhase(connectionState)) { + case "synchronizing": + return setSynchronizing; + case "disconnected": + return setDisconnected; + case "ready": + return setReady; + } + }), + Effect.forkScoped, + ); + + return state; +}); + +export function shellStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap(makeEnvironmentShellState().pipe(Effect.map(SubscriptionRef.changes))), + ); +} + +export interface EnvironmentShellSummary { + readonly hasSnapshot: boolean; + readonly hasSynchronizingShell: boolean; + readonly hasCachedShell: boolean; + readonly hasLiveShell: boolean; + readonly firstError: string | null; + readonly latestSnapshotUpdatedAt: string | null; +} + +const EMPTY_ENVIRONMENT_SHELL_SUMMARY: EnvironmentShellSummary = Object.freeze({ + hasSnapshot: false, + hasSynchronizingShell: false, + hasCachedShell: false, + hasLiveShell: false, + firstError: null, + latestSnapshotUpdatedAt: null, +}); + +const EMPTY_SERVER_CONFIGS: ReadonlyMap = new Map(); + +function shellSummariesEqual( + left: EnvironmentShellSummary, + right: EnvironmentShellSummary, +): boolean { + return ( + left.hasSnapshot === right.hasSnapshot && + left.hasSynchronizingShell === right.hasSynchronizingShell && + left.hasCachedShell === right.hasCachedShell && + left.hasLiveShell === right.hasLiveShell && + left.firstError === right.firstError && + left.latestSnapshotUpdatedAt === right.latestSnapshotUpdatedAt + ); +} + +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false; + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false; + } + } + return true; +} + +export function createEnvironmentShellSummaryAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly shellStateValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + let previousSummary = EMPTY_ENVIRONMENT_SHELL_SUMMARY; + return Atom.make((get) => { + let hasSnapshot = false; + let hasSynchronizingShell = false; + let hasCachedShell = false; + let hasLiveShell = false; + let firstError: string | null = null; + let latestSnapshotUpdatedAt: string | null = null; + + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const state = get(input.shellStateValueAtom(environmentId)); + hasSynchronizingShell ||= state.status === "synchronizing"; + hasCachedShell ||= state.status === "cached"; + hasLiveShell ||= state.status === "live"; + if (firstError === null) { + firstError = Option.getOrNull(state.error); + } + if (Option.isNone(state.snapshot)) { + continue; + } + hasSnapshot = true; + const updatedAt = state.snapshot.value.updatedAt; + if (latestSnapshotUpdatedAt === null || updatedAt > latestSnapshotUpdatedAt) { + latestSnapshotUpdatedAt = updatedAt; + } + } + + const next: EnvironmentShellSummary = { + hasSnapshot, + hasSynchronizingShell, + hasCachedShell, + hasLiveShell, + firstError, + latestSnapshotUpdatedAt, + }; + if (shellSummariesEqual(previousSummary, next)) { + return previousSummary; + } + previousSummary = next; + return previousSummary; + }).pipe(Atom.withLabel("environment-shell-summary")); +} + +export function createEnvironmentServerConfigsAtom(input: { + readonly catalogValueAtom: Atom.Atom; + readonly configValueAtom: (environmentId: EnvironmentId) => Atom.Atom; +}) { + let previousServerConfigs = EMPTY_SERVER_CONFIGS; + return Atom.make((get) => { + const next = new Map(); + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + const config = get(input.configValueAtom(environmentId)); + if (config !== null) { + next.set(environmentId, config); + } + } + if (mapsEqual(previousServerConfigs, next)) { + return previousServerConfigs; + } + previousServerConfigs = next; + return previousServerConfigs; + }).pipe(Atom.withLabel("environment-server-configs")); +} + +export function createEnvironmentShellAtoms( + runtime: Atom.AtomRuntime, +) { + const stateAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom(shellStateChanges(environmentId), { + initialValue: EMPTY_SHELL_STATE, + }), + ); + + const stateValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + Option.getOrElse(AsyncResult.value(get(stateAtom(environmentId))), () => EMPTY_SHELL_STATE), + ).pipe(Atom.withLabel(`environment-shell-state-value:${environmentId}`)), + ); + + return { + stateAtom, + stateValueAtom, + }; +} + +export * from "./models.ts"; +export * from "./shellCommands.ts"; +export * from "./shellReducer.ts"; +export * from "./snapshots.ts"; diff --git a/packages/client-runtime/src/state/shellCommands.ts b/packages/client-runtime/src/state/shellCommands.ts new file mode 100644 index 00000000000..009f0782b2a --- /dev/null +++ b/packages/client-runtime/src/state/shellCommands.ts @@ -0,0 +1,16 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcMutation } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createShellEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + openInEditor: createEnvironmentRpcMutation(runtime, { + label: "environment-data:shell:open-in-editor", + tag: WS_METHODS.shellOpenInEditor, + }), + }; +} diff --git a/packages/client-runtime/src/shellSnapshotReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts similarity index 98% rename from packages/client-runtime/src/shellSnapshotReducer.test.ts rename to packages/client-runtime/src/state/shellReducer.test.ts index 69ae5e5d69f..4689c1408f7 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; -import { applyShellStreamEvent } from "./shellSnapshotReducer.ts"; +import { applyShellStreamEvent } from "./shellReducer.ts"; const baseSnapshot: OrchestrationShellSnapshot = { snapshotSequence: 0, diff --git a/packages/client-runtime/src/shellSnapshotReducer.ts b/packages/client-runtime/src/state/shellReducer.ts similarity index 95% rename from packages/client-runtime/src/shellSnapshotReducer.ts rename to packages/client-runtime/src/state/shellReducer.ts index a30eedb769b..71c8a6b0eb3 100644 --- a/packages/client-runtime/src/shellSnapshotReducer.ts +++ b/packages/client-runtime/src/state/shellReducer.ts @@ -2,7 +2,7 @@ import * as Arr from "effect/Array"; import type { OrchestrationShellSnapshot, OrchestrationShellStreamEvent } from "@t3tools/contracts"; /** - * Apply a single shell stream event to an existing snapshot, returning a new + * Reduce a single shell stream event into an existing snapshot, returning a new * snapshot with the event's changes applied. This is a pure reducer that both * web and mobile can use to keep their local shell snapshot in sync. * diff --git a/packages/client-runtime/src/state/snapshots.ts b/packages/client-runtime/src/state/snapshots.ts new file mode 100644 index 00000000000..0000dcb12ce --- /dev/null +++ b/packages/client-runtime/src/state/snapshots.ts @@ -0,0 +1,20 @@ +import type { EnvironmentId, OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentShellState } from "./shell.ts"; + +export function createEnvironmentSnapshotAtom( + shellStateAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>, +) { + return Atom.family((environmentId: EnvironmentId) => + Atom.make((get): OrchestrationShellSnapshot | null => + Option.match(AsyncResult.value(get(shellStateAtom(environmentId))), { + onNone: () => null, + onSome: (state) => Option.getOrNull(state.snapshot), + }), + ).pipe(Atom.withLabel(`environment-snapshot:${environmentId}`)), + ); +} diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts new file mode 100644 index 00000000000..ff412af384a --- /dev/null +++ b/packages/client-runtime/src/state/sourceControl.ts @@ -0,0 +1,32 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcMutation, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createSourceControlEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + discovery: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:source-control-discovery", + tag: WS_METHODS.serverDiscoverSourceControl, + }), + repository: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:source-control:repository", + tag: WS_METHODS.sourceControlLookupRepository, + }), + lookupRepository: createEnvironmentRpcMutation(runtime, { + label: "environment-data:source-control:lookup-repository", + tag: WS_METHODS.sourceControlLookupRepository, + }), + cloneRepository: createEnvironmentRpcMutation(runtime, { + label: "environment-data:source-control:clone-repository", + tag: WS_METHODS.sourceControlCloneRepository, + }), + publishRepository: createEnvironmentRpcMutation(runtime, { + label: "environment-data:source-control:publish-repository", + tag: WS_METHODS.sourceControlPublishRepository, + }), + }; +} diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts new file mode 100644 index 00000000000..2e8a771aea5 --- /dev/null +++ b/packages/client-runtime/src/state/terminal.ts @@ -0,0 +1,67 @@ +import { type TerminalSummary, WS_METHODS } from "@t3tools/contracts"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcMutation, + createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { + applyTerminalAttachStreamEvent, + applyTerminalMetadataStreamEvent, + EMPTY_TERMINAL_BUFFER_STATE, +} from "./terminalSession.ts"; + +export function createTerminalEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + attach: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:attach", + subscribe: (input: EnvironmentRpcInput) => + subscribe(WS_METHODS.terminalAttach, input).pipe( + Stream.scan(EMPTY_TERMINAL_BUFFER_STATE, applyTerminalAttachStreamEvent), + ), + }), + events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:events", + tag: WS_METHODS.subscribeTerminalEvents, + }), + metadata: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:metadata", + subscribe: (_input: null) => + subscribe(WS_METHODS.subscribeTerminalMetadata, {}).pipe( + Stream.scan([] as ReadonlyArray, applyTerminalMetadataStreamEvent), + ), + }), + open: createEnvironmentRpcMutation(runtime, { + label: "environment-data:terminal:open", + tag: WS_METHODS.terminalOpen, + }), + write: createEnvironmentRpcMutation(runtime, { + label: "environment-data:terminal:write", + tag: WS_METHODS.terminalWrite, + }), + resize: createEnvironmentRpcMutation(runtime, { + label: "environment-data:terminal:resize", + tag: WS_METHODS.terminalResize, + }), + clear: createEnvironmentRpcMutation(runtime, { + label: "environment-data:terminal:clear", + tag: WS_METHODS.terminalClear, + }), + restart: createEnvironmentRpcMutation(runtime, { + label: "environment-data:terminal:restart", + tag: WS_METHODS.terminalRestart, + }), + close: createEnvironmentRpcMutation(runtime, { + label: "environment-data:terminal:close", + tag: WS_METHODS.terminalClose, + }), + }; +} + +export * from "./terminalSession.ts"; diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts new file mode 100644 index 00000000000..cf5634b0875 --- /dev/null +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { EnvironmentId, TerminalSessionSnapshot, ThreadId } from "@t3tools/contracts"; + +import { + applyTerminalAttachStreamEvent, + applyTerminalMetadataStreamEvent, + combineTerminalSessionState, + EMPTY_TERMINAL_BUFFER_STATE, +} from "./terminalSession.ts"; + +const TARGET = { + environmentId: EnvironmentId.make("env-local"), + threadId: ThreadId.make("thread-1"), + terminalId: "term-1", +} as const; + +const BASE_SNAPSHOT: TerminalSessionSnapshot = { + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + cwd: "/repo", + worktreePath: null, + status: "running", + pid: 123, + history: "hello", + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: "2026-04-01T00:00:00.000Z", +}; + +describe("terminal session reducers", () => { + it("prefers live attach status over stale metadata after the attach stream starts", () => { + const summary = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: "running", + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + })[0]!; + const attached = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "error", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + message: "Terminal disconnected.", + }); + + expect(combineTerminalSessionState(summary, attached)).toMatchObject({ + status: "error", + error: "Terminal disconnected.", + version: 1, + }); + }); + + it("uses metadata status before an attach stream has emitted", () => { + const summary = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: "running", + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + })[0]!; + + expect(combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE).status).toBe( + "running", + ); + }); + + it("reduces attach snapshots and output without an imperative session manager", () => { + const snapshot = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, { + type: "snapshot", + snapshot: BASE_SNAPSHOT, + }); + const output = applyTerminalAttachStreamEvent( + snapshot, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: " world", + }, + 8, + ); + + expect(output).toMatchObject({ + buffer: "lo world", + status: "running", + error: null, + version: 2, + }); + }); + + it("reduces terminal metadata snapshots, upserts, and removals", () => { + const initial = applyTerminalMetadataStreamEvent([], { + type: "snapshot", + terminals: [ + { + threadId: BASE_SNAPSHOT.threadId, + terminalId: BASE_SNAPSHOT.terminalId, + cwd: BASE_SNAPSHOT.cwd, + worktreePath: BASE_SNAPSHOT.worktreePath, + status: BASE_SNAPSHOT.status, + pid: BASE_SNAPSHOT.pid, + exitCode: BASE_SNAPSHOT.exitCode, + exitSignal: BASE_SNAPSHOT.exitSignal, + updatedAt: BASE_SNAPSHOT.updatedAt, + hasRunningSubprocess: false, + label: BASE_SNAPSHOT.label, + }, + ], + }); + const updated = applyTerminalMetadataStreamEvent(initial, { + type: "upsert", + terminal: { + ...initial[0]!, + hasRunningSubprocess: true, + }, + }); + const removed = applyTerminalMetadataStreamEvent(updated, { + type: "remove", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + }); + + expect(updated).toHaveLength(1); + expect(updated[0]?.hasRunningSubprocess).toBe(true); + expect(removed).toEqual([]); + }); + + it("caps retained output by UTF-8 byte length", () => { + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "🙂🙂", + }, + 4, + ); + + expect(state.buffer).toBe("🙂"); + }); +}); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts new file mode 100644 index 00000000000..905cdd27d1f --- /dev/null +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -0,0 +1,186 @@ +import type { + EnvironmentId, + TerminalAttachStreamEvent, + TerminalMetadataStreamEvent, + TerminalSessionSnapshot, + TerminalSummary, + ThreadId, +} from "@t3tools/contracts"; + +export interface TerminalSessionState { + readonly summary: TerminalSummary | null; + readonly buffer: string; + readonly status: TerminalSessionSnapshot["status"] | "closed"; + readonly error: string | null; + readonly hasRunningSubprocess: boolean; + readonly updatedAt: string | null; + readonly version: number; +} + +export interface TerminalBufferState { + readonly buffer: string; + readonly status: TerminalSessionSnapshot["status"] | "closed"; + readonly error: string | null; + readonly updatedAt: string | null; + readonly version: number; +} + +export interface KnownTerminalSessionTarget { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly terminalId: string; +} + +export interface KnownTerminalSession { + readonly target: KnownTerminalSessionTarget; + readonly state: TerminalSessionState; +} + +export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ + buffer: "", + status: "closed", + error: null, + updatedAt: null, + version: 0, +}); + +export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ + summary: null, + buffer: "", + status: "closed", + error: null, + hasRunningSubprocess: false, + updatedAt: null, + version: 0, +}); + +export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { + if (maxBufferBytes <= 0) { + return ""; + } + + const encoded = textEncoder.encode(buffer); + if (encoded.byteLength <= maxBufferBytes) { + return buffer; + } + + let start = encoded.byteLength - maxBufferBytes; + while (start < encoded.length) { + const byte = encoded[start]; + if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { + break; + } + start += 1; + } + + return textDecoder.decode(encoded.subarray(start)); +} + +export function terminalBufferStateFromSnapshot( + snapshot: TerminalSessionSnapshot, + maxBufferBytes: number, +): TerminalBufferState { + return { + buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), + status: snapshot.status, + error: null, + updatedAt: snapshot.updatedAt, + version: 1, + }; +} + +function latestTimestamp(left: string | null, right: string | null): string | null { + if (left === null) return right; + if (right === null) return left; + return Date.parse(left) >= Date.parse(right) ? left : right; +} + +export function combineTerminalSessionState( + summary: TerminalSummary | null, + buffer: TerminalBufferState, +): TerminalSessionState { + return { + summary, + buffer: buffer.buffer, + status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), + error: buffer.error, + hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, + updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), + version: buffer.version, + }; +} + +export function applyTerminalAttachStreamEvent( + current: TerminalBufferState, + event: TerminalAttachStreamEvent, + maxBufferBytes = DEFAULT_MAX_TERMINAL_BUFFER_BYTES, +): TerminalBufferState { + switch (event.type) { + case "snapshot": + case "restarted": + return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); + case "output": + return { + ...current, + buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), + status: current.status === "closed" ? "running" : current.status, + error: null, + version: current.version + 1, + }; + case "cleared": + return { + ...current, + buffer: "", + error: null, + version: current.version + 1, + }; + case "exited": + return { + ...current, + status: "exited", + error: null, + version: current.version + 1, + }; + case "closed": + return { + ...current, + status: "closed", + error: null, + version: current.version + 1, + }; + case "error": + return { + ...current, + status: "error", + error: event.message, + version: current.version + 1, + }; + case "activity": + return current; + } +} + +export function applyTerminalMetadataStreamEvent( + current: ReadonlyArray, + event: TerminalMetadataStreamEvent, +): ReadonlyArray { + if (event.type === "snapshot") { + return event.terminals; + } + if (event.type === "remove") { + return current.filter( + (terminal) => + terminal.threadId !== event.threadId || terminal.terminalId !== event.terminalId, + ); + } + const next = current.filter( + (terminal) => + terminal.threadId !== event.terminal.threadId || + terminal.terminalId !== event.terminal.terminalId, + ); + return [...next, event.terminal]; +} diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts new file mode 100644 index 00000000000..bafb9662306 --- /dev/null +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -0,0 +1,108 @@ +import * as Crypto from "effect/Crypto"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentMutation } from "./runtime.ts"; +import { + type ArchiveThreadInput, + type CreateThreadInput, + type DeleteThreadInput, + type InterruptThreadTurnInput, + type RespondToThreadApprovalInput, + type RespondToThreadUserInputInput, + type RevertThreadCheckpointInput, + type SetThreadInteractionModeInput, + type SetThreadRuntimeModeInput, + type StartThreadTurnInput, + type StopThreadSessionInput, + type UnarchiveThreadInput, + type UpdateThreadMetadataInput, + archiveThread, + createThread, + deleteThread, + interruptThreadTurn, + respondToThreadApproval, + respondToThreadUserInput, + revertThreadCheckpoint, + setThreadInteractionMode, + setThreadRuntimeMode, + startThreadTurn, + stopThreadSession, + unarchiveThread, + updateThreadMetadata, +} from "../operations/commands.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export type { + ArchiveThreadInput, + CreateThreadInput, + DeleteThreadInput, + InterruptThreadTurnInput, + RespondToThreadApprovalInput, + RespondToThreadUserInputInput, + RevertThreadCheckpointInput, + SetThreadInteractionModeInput, + SetThreadRuntimeModeInput, + StartThreadTurnInput, + StopThreadSessionInput, + UnarchiveThreadInput, + UpdateThreadMetadataInput, +} from "../operations/commands.ts"; + +export function createThreadEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + create: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:create", + execute: (input: CreateThreadInput) => createThread(input), + }), + delete: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:delete", + execute: (input: DeleteThreadInput) => deleteThread(input), + }), + archive: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:archive", + execute: (input: ArchiveThreadInput) => archiveThread(input), + }), + unarchive: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:unarchive", + execute: (input: UnarchiveThreadInput) => unarchiveThread(input), + }), + updateMetadata: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:update-metadata", + execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), + }), + setRuntimeMode: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:set-runtime-mode", + execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), + }), + setInteractionMode: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:set-interaction-mode", + execute: (input: SetThreadInteractionModeInput) => setThreadInteractionMode(input), + }), + startTurn: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:start-turn", + execute: (input: StartThreadTurnInput) => startThreadTurn(input), + }), + interruptTurn: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:interrupt-turn", + execute: (input: InterruptThreadTurnInput) => interruptThreadTurn(input), + }), + respondToApproval: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:respond-to-approval", + execute: (input: RespondToThreadApprovalInput) => respondToThreadApproval(input), + }), + respondToUserInput: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:respond-to-user-input", + execute: (input: RespondToThreadUserInputInput) => respondToThreadUserInput(input), + }), + revertCheckpoint: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:revert-checkpoint", + execute: (input: RevertThreadCheckpointInput) => revertThreadCheckpoint(input), + }), + stopSession: createEnvironmentMutation(runtime, { + label: "environment-data:commands:thread:stop-session", + execute: (input: StopThreadSessionInput) => stopThreadSession(input), + }), + }; +} diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts new file mode 100644 index 00000000000..f96f67efe02 --- /dev/null +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -0,0 +1,146 @@ +import type { + OrchestrationCheckpointSummary, + OrchestrationLatestTurn, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationSession, + OrchestrationThread, + OrchestrationThreadActivity, + ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentThread } from "./models.ts"; +import { scopeThread } from "./models.ts"; +import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState } from "./threads.ts"; +import { parseThreadKey, threadKey } from "./entities.ts"; + +const EMPTY_MESSAGES: ReadonlyArray = Object.freeze([]); +const EMPTY_ACTIVITIES: ReadonlyArray = Object.freeze([]); +const EMPTY_PROPOSED_PLANS: ReadonlyArray = Object.freeze([]); +const EMPTY_CHECKPOINTS: ReadonlyArray = Object.freeze([]); +const THREAD_DETAIL_IDLE_TTL_MS = 5 * 60_000; + +export function createEnvironmentThreadDetailAtoms( + threadStateAtom: ( + environmentId: ScopedThreadRef["environmentId"], + threadId: ScopedThreadRef["threadId"], + ) => Atom.Atom>, +) { + const threadStateValueAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + return Atom.make((get) => + Option.getOrElse( + AsyncResult.value(get(threadStateAtom(ref.environmentId, ref.threadId))), + () => EMPTY_ENVIRONMENT_THREAD_STATE, + ), + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-state-value:${key}`), + ); + }); + + const threadDetailAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previousSource: OrchestrationThread | null = null; + let previousValue: EnvironmentThread | null = null; + return Atom.make((get) => { + const source = Option.getOrNull(get(threadStateValueAtomFamily(key)).data); + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeThread(ref.environmentId, source); + return previousValue; + }).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-detail:${key}`), + ); + }); + + const threadStatusAtomFamily = Atom.family((key: string) => + Atom.make((get) => get(threadStateValueAtomFamily(key)).status).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-status:${key}`), + ), + ); + + const threadErrorAtomFamily = Atom.family((key: string) => + Atom.make((get) => Option.getOrNull(get(threadStateValueAtomFamily(key)).error)).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-error:${key}`), + ), + ); + + const threadMessagesAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.messages ?? EMPTY_MESSAGES, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-messages:${key}`), + ), + ); + + const threadActivitiesAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.activities ?? EMPTY_ACTIVITIES, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-activities:${key}`), + ), + ); + + const threadProposedPlansAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.proposedPlans ?? EMPTY_PROPOSED_PLANS, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-proposed-plans:${key}`), + ), + ); + + const threadCheckpointsAtomFamily = Atom.family((key: string) => + Atom.make( + (get): ReadonlyArray => + get(threadDetailAtomFamily(key))?.checkpoints ?? EMPTY_CHECKPOINTS, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-checkpoints:${key}`), + ), + ); + + const threadSessionAtomFamily = Atom.family((key: string) => + Atom.make( + (get): OrchestrationSession | null => get(threadDetailAtomFamily(key))?.session ?? null, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-session:${key}`), + ), + ); + + const threadLatestTurnAtomFamily = Atom.family((key: string) => + Atom.make( + (get): OrchestrationLatestTurn | null => get(threadDetailAtomFamily(key))?.latestTurn ?? null, + ).pipe( + Atom.setIdleTTL(THREAD_DETAIL_IDLE_TTL_MS), + Atom.withLabel(`environment-thread-latest-turn:${key}`), + ), + ); + + return { + stateAtom: (ref: ScopedThreadRef) => threadStateValueAtomFamily(threadKey(ref)), + detailAtom: (ref: ScopedThreadRef) => threadDetailAtomFamily(threadKey(ref)), + statusAtom: (ref: ScopedThreadRef) => threadStatusAtomFamily(threadKey(ref)), + errorAtom: (ref: ScopedThreadRef) => threadErrorAtomFamily(threadKey(ref)), + messagesAtom: (ref: ScopedThreadRef) => threadMessagesAtomFamily(threadKey(ref)), + activitiesAtom: (ref: ScopedThreadRef) => threadActivitiesAtomFamily(threadKey(ref)), + proposedPlansAtom: (ref: ScopedThreadRef) => threadProposedPlansAtomFamily(threadKey(ref)), + checkpointsAtom: (ref: ScopedThreadRef) => threadCheckpointsAtomFamily(threadKey(ref)), + sessionAtom: (ref: ScopedThreadRef) => threadSessionAtomFamily(threadKey(ref)), + latestTurnAtom: (ref: ScopedThreadRef) => threadLatestTurnAtomFamily(threadKey(ref)), + }; +} diff --git a/packages/client-runtime/src/threadDetailReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts similarity index 99% rename from packages/client-runtime/src/threadDetailReducer.test.ts rename to packages/client-runtime/src/state/threadReducer.test.ts index f2af7284083..a247a5da5e1 100644 --- a/packages/client-runtime/src/threadDetailReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -11,7 +11,7 @@ import { } from "@t3tools/contracts"; import type { OrchestrationThread } from "@t3tools/contracts"; -import { applyThreadDetailEvent } from "./threadDetailReducer.ts"; +import { applyThreadDetailEvent } from "./threadReducer.ts"; const baseEventFields = { eventId: EventId.make("event-1"), diff --git a/packages/client-runtime/src/threadDetailReducer.ts b/packages/client-runtime/src/state/threadReducer.ts similarity index 99% rename from packages/client-runtime/src/threadDetailReducer.ts rename to packages/client-runtime/src/state/threadReducer.ts index 53bad5785b9..e017a1c15b8 100644 --- a/packages/client-runtime/src/threadDetailReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -14,7 +14,7 @@ import type { } from "@t3tools/contracts"; /** - * Retention limits for collections within a thread. + * Retention limits for collections within synchronized thread state. * These prevent unbounded growth of in-memory thread state. */ export interface ThreadDetailRetentionLimits { diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts new file mode 100644 index 00000000000..65cee0427eb --- /dev/null +++ b/packages/client-runtime/src/state/threadShell.ts @@ -0,0 +1,186 @@ +import type { + EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThreadShell, + ProjectId, + ScopedProjectRef, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentThreadShell } from "./models.ts"; +import { scopeThreadShell } from "./models.ts"; +import type { EnvironmentCatalogState } from "./connections.ts"; +import { + arrayElementsEqual, + parseProjectRefCollectionKey, + parseThreadKey, + projectRefCollectionKey, + threadKey, + threadRefsEqual, +} from "./entities.ts"; + +const EMPTY_THREADS: ReadonlyArray = Object.freeze([]); +const EMPTY_SCOPED_THREAD_REFS: ReadonlyArray = Object.freeze([]); +const EMPTY_THREAD_INDEX: ReadonlyMap = new Map(); +const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< + ProjectId, + ReadonlyArray +> = new Map(); + +export function createEnvironmentThreadShellAtoms(input: { + readonly catalogValueAtom: Atom.Atom; + readonly snapshotAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom; +}) { + const environmentThreadsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): ReadonlyArray => + get(input.snapshotAtom(environmentId))?.threads ?? EMPTY_THREADS, + ).pipe(Atom.withLabel(`environment-threads:${environmentId}`)), + ); + + const environmentThreadIndexAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get): ReadonlyMap => { + const threads = get(environmentThreadsAtom(environmentId)); + if (threads.length === 0) { + return EMPTY_THREAD_INDEX; + } + return new Map(threads.map((thread) => [thread.id, thread] as const)); + }).pipe(Atom.withLabel(`environment-thread-index:${environmentId}`)), + ); + + const environmentThreadRefsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next = get(environmentThreadsAtom(environmentId)).map((thread) => ({ + environmentId, + threadId: thread.id, + })); + if (threadRefsEqual(previous, next)) { + return previous; + } + previous = next; + return next; + }).pipe(Atom.withLabel(`environment-thread-refs:${environmentId}`)); + }); + + const environmentThreadRefsByProjectAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyMap< + ProjectId, + ReadonlyArray + > = EMPTY_THREAD_REFS_BY_PROJECT; + return Atom.make((get) => { + const grouped = new Map(); + for (const thread of get(environmentThreadsAtom(environmentId))) { + const refs = grouped.get(thread.projectId); + const ref = { environmentId, threadId: thread.id }; + if (refs === undefined) { + grouped.set(thread.projectId, [ref]); + } else { + refs.push(ref); + } + } + if (grouped.size === 0) { + previous = EMPTY_THREAD_REFS_BY_PROJECT; + return previous; + } + const next = new Map>(); + for (const [projectId, refs] of grouped) { + const previousRefs = previous.get(projectId); + next.set( + projectId, + previousRefs !== undefined && threadRefsEqual(previousRefs, refs) ? previousRefs : refs, + ); + } + previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`)); + }); + + const threadShellAtomFamily = Atom.family((key: string) => { + const ref = parseThreadKey(key); + let previousSource: OrchestrationThreadShell | null = null; + let previousValue: EnvironmentThreadShell | null = null; + return Atom.make((get) => { + const source = get(environmentThreadIndexAtom(ref.environmentId)).get(ref.threadId) ?? null; + if (source === previousSource) { + return previousValue; + } + previousSource = source; + previousValue = source === null ? null : scopeThreadShell(ref.environmentId, source); + return previousValue; + }).pipe(Atom.withLabel(`environment-thread-shell:${key}`)); + }); + + const threadShellsForProjectRefsAtomFamily = Atom.family((key: string) => { + const projectRefs = parseProjectRefCollectionKey(key); + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const next: EnvironmentThreadShell[] = []; + const seen = new Set(); + for (const projectRef of projectRefs) { + const refs = + get(environmentThreadRefsByProjectAtom(projectRef.environmentId)).get( + projectRef.projectId, + ) ?? EMPTY_SCOPED_THREAD_REFS; + for (const ref of refs) { + const key = threadKey(ref); + if (seen.has(key)) { + continue; + } + seen.add(key); + const thread = get(threadShellAtomFamily(key)); + if (thread !== null) { + next.push(thread); + } + } + } + if (arrayElementsEqual(previous, next)) { + return previous; + } + previous = next; + return previous; + }).pipe(Atom.withLabel(`environment-thread-shells-for-projects:${key}`)); + }); + + let previousThreadRefs: ReadonlyArray = []; + const threadRefsAtom = Atom.make((get) => { + const refs: ScopedThreadRef[] = []; + for (const environmentId of get(input.catalogValueAtom).entries.keys()) { + refs.push(...get(environmentThreadRefsAtom(environmentId))); + } + if (threadRefsEqual(previousThreadRefs, refs)) { + return previousThreadRefs; + } + previousThreadRefs = refs; + return refs; + }).pipe(Atom.withLabel("environment-thread-refs")); + + let previousThreadShells: ReadonlyArray = []; + const threadShellsAtom = Atom.make((get) => { + const next = get(threadRefsAtom).flatMap((ref) => { + const thread = get(threadShellAtomFamily(threadKey(ref))); + return thread === null ? [] : [thread]; + }); + if (arrayElementsEqual(previousThreadShells, next)) { + return previousThreadShells; + } + previousThreadShells = next; + return previousThreadShells; + }).pipe(Atom.withLabel("environment-thread-shell-list")); + + return { + environmentThreadsAtom, + environmentThreadIndexAtom, + environmentThreadRefsAtom, + environmentThreadRefsByProjectAtom, + threadRefsAtom, + threadShellsAtom, + threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => + threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), + threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + }; +} diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts new file mode 100644 index 00000000000..d2bccf79142 --- /dev/null +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -0,0 +1,344 @@ +import { + EnvironmentId, + EventId, + ORCHESTRATION_WS_METHODS, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationThread, + type OrchestrationThreadStreamItem, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; + +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import { + EnvironmentSupervisor, + type EnvironmentSupervisorService, +} from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + makeEnvironmentThreadState, + type EnvironmentThreadState, +} from "./threads.ts"; +import { DEFAULT_THREAD_DETAIL_LIMITS } from "./threadReducer.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); +const THREAD_ID = ThreadId.make("thread-1"); +const BASE_THREAD: OrchestrationThread = { + id: THREAD_ID, + projectId: ProjectId.make("project-1"), + title: "Cached thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, +}; + +type TestThreadInput = OrchestrationThreadStreamItem | Error; + +function testSession(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +function awaitThreadState( + observed: Queue.Queue, + predicate: (state: EnvironmentThreadState) => boolean, +) { + return Queue.take(observed).pipe( + Effect.repeat({ + until: predicate, + }), + ); +} + +const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { + readonly cached?: OrchestrationThread; +}) { + const inputs = yield* Queue.unbounded(); + const observed = yield* Queue.unbounded(); + const latest = yield* Ref.make(EMPTY_ENVIRONMENT_THREAD_STATE); + const retryCount = yield* Ref.make(0); + const subscriptionCount = yield* Ref.make(0); + const savedThreads = yield* Ref.make>([]); + const removedThreads = yield* Ref.make>([]); + const supervisorState = yield* SubscriptionRef.make( + AVAILABLE_CONNECTION_STATE, + ); + const streamFrom = (queue: Queue.Queue) => + Stream.fromQueue(queue).pipe( + Stream.mapEffect((input) => + input instanceof Error ? Effect.fail(input) : Effect.succeed(input), + ), + ); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeThread]: () => + Stream.unwrap( + Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe( + Effect.map(() => streamFrom(inputs)), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisorSession = yield* SubscriptionRef.make>( + Option.some(testSession(client)), + ); + const prepared = yield* SubscriptionRef.make>(Option.none()); + const supervisor = EnvironmentSupervisor.of({ + target: TARGET, + state: supervisorState, + session: supervisorSession, + prepared, + connect: Effect.void, + disconnect: Effect.void, + retryNow: Ref.update(retryCount, (count) => count + 1), + } satisfies EnvironmentSupervisorService); + const cache = EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: (_environmentId, threadId) => + Effect.succeed( + threadId === THREAD_ID && options?.cached !== undefined + ? Option.some(options.cached) + : Option.none(), + ), + saveThread: (_environmentId, thread) => + Ref.update(savedThreads, (current) => [...current, thread]), + removeThread: (_environmentId, threadId) => + Ref.update(removedThreads, (current) => [...current, threadId]), + clear: () => Effect.void, + }); + const threadState = yield* makeEnvironmentThreadState( + THREAD_ID, + DEFAULT_THREAD_DETAIL_LIMITS, + ).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.provideService(EnvironmentCacheStore, cache), + ); + yield* SubscriptionRef.changes(threadState).pipe( + Stream.runForEach((state) => + Ref.set(latest, state).pipe(Effect.andThen(Queue.offer(observed, state))), + ), + Effect.forkScoped, + ); + + return { + inputs, + observed, + latest, + retryCount, + subscriptionCount, + supervisorState, + supervisorSession, + savedThreads, + removedThreads, + }; +}); + +const snapshot = (thread: OrchestrationThread): OrchestrationThreadStreamItem => ({ + kind: "snapshot", + snapshot: { + snapshotSequence: 1, + thread, + }, +}); + +const titleUpdated = (title: string, sequence = 2): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-title"), + sequence, + occurredAt: "2026-04-01T01:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title, + updatedAt: "2026-04-01T01:00:00.000Z", + }, + }, +}); + +const deleted = (): OrchestrationThreadStreamItem => ({ + kind: "event", + event: { + eventId: EventId.make("event-deleted"), + sequence: 3, + occurredAt: "2026-04-01T02:00:00.000Z", + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.deleted", + payload: { + threadId: THREAD_ID, + deletedAt: "2026-04-01T02:00:00.000Z", + }, + }, +}); + +describe("EnvironmentThreads", () => { + it.effect("publishes cached data before a live snapshot arrives", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "cached" && Option.isSome(value.data), + ); + + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(Option.isNone(state.error)).toBe(true); + }), + ); + + it.effect("reduces live events and persists the latest thread", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, titleUpdated("Live title")); + + const state = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + yield* TestClock.adjust("500 millis"); + yield* Effect.yieldNow; + + expect(Option.getOrThrow(state.data).title).toBe("Live title"); + expect((yield* Ref.get(harness.savedThreads)).at(-1)?.title).toBe("Live title"); + }), + ); + + it.effect("ignores replayed thread events at or below the snapshot sequence", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, titleUpdated("Replayed title", 1)); + yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2)); + + const state = yield* awaitThreadState( + harness.observed, + (value) => + value.status === "live" && + Option.isSome(value.data) && + value.data.value.title === "Live title", + ); + + expect(Option.getOrThrow(state.data).title).toBe("Live title"); + }), + ); + + it.effect("removes cached data when the thread is deleted", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, deleted()); + + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "deleted", + ); + + expect(Option.isNone(state.data)).toBe(true); + expect(yield* Ref.get(harness.removedThreads)).toEqual([THREAD_ID]); + }), + ); + + it.effect("preserves the latest data and surfaces a domain stream failure", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* Queue.offer(harness.inputs, new Error("stream failed")); + + const state = yield* awaitThreadState(harness.observed, (value) => + Option.isSome(value.error), + ); + + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(Option.getOrThrow(state.error)).toBe("stream failed"); + expect(yield* Ref.get(harness.retryCount)).toBe(0); + }), + ); + + it.effect("does not overwrite a live snapshot when the supervisor becomes ready", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ cached: BASE_THREAD }); + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connecting", + stage: "synchronizing", + attempt: 1, + generation: 0, + lastFailure: null, + retryAt: null, + }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + yield* awaitThreadState(harness.observed, (value) => value.status === "live"); + + yield* SubscriptionRef.set(harness.supervisorState, { + desired: true, + network: "online", + phase: "connected", + stage: null, + attempt: 1, + generation: 1, + lastFailure: null, + retryAt: null, + }); + for (let index = 0; index < 10; index += 1) { + yield* Effect.yieldNow; + } + + expect((yield* Ref.get(harness.latest)).status).toBe("live"); + }), + ); +}); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts new file mode 100644 index 00000000000..fd6804b5ad9 --- /dev/null +++ b/packages/client-runtime/src/state/threads.ts @@ -0,0 +1,283 @@ +import { + EnvironmentId, + ORCHESTRATION_WS_METHODS, + ThreadId, + type EnvironmentId as EnvironmentIdType, + type OrchestrationThread, + type OrchestrationThreadStreamItem, + type ThreadId as ThreadIdType, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { Atom } from "effect/unstable/reactivity"; + +import { EnvironmentRegistry } from "../connection/registry.ts"; +import { connectionProjectionPhase } from "../connection/model.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { subscribe } from "../rpc/client.ts"; +import { + DEFAULT_THREAD_DETAIL_LIMITS, + applyThreadDetailEvent, + type ThreadDetailRetentionLimits, +} from "./threadReducer.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; + +export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; + +export interface EnvironmentThreadState { + readonly data: Option.Option; + readonly status: EnvironmentThreadStatus; + readonly error: Option.Option; +} + +export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { + data: Option.none(), + status: "empty", + error: Option.none(), +}; + +function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { + return Option.isSome(data) ? "cached" : "empty"; +} + +function formatThreadError(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Could not synchronize the thread."; +} + +export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( + threadId: ThreadIdType, + limits: ThreadDetailRetentionLimits, +) { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const cached = yield* cache.loadThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + Effect.as(Option.none()), + ), + ), + ); + const state = yield* SubscriptionRef.make({ + data: cached, + status: statusWithoutLiveData(cached), + error: Option.none(), + }); + const lastSequence = yield* SubscriptionRef.make(0); + const persistence = yield* Queue.sliding(1); + + const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( + thread: OrchestrationThread, + ) { + yield* cache.saveThread(environmentId, thread).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the thread cache.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); + }); + + yield* Stream.fromQueue(persistence).pipe( + Stream.debounce("500 millis"), + Stream.runForEach(persist), + Effect.forkScoped, + ); + + const setSynchronizing = SubscriptionRef.update(state, (current) => ({ + ...current, + status: "synchronizing" as const, + error: Option.none(), + })); + const setReady = SubscriptionRef.update(state, (current) => + current.status === "live" || current.status === "deleted" + ? current + : { + ...current, + status: "synchronizing" as const, + error: Option.none(), + }, + ); + const setDisconnected = SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + })); + const setStreamError = (cause: Cause.Cause) => + SubscriptionRef.update(state, (current) => ({ + ...current, + status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), + error: Option.some(formatThreadError(cause)), + })); + + const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( + thread: OrchestrationThread, + ) { + yield* SubscriptionRef.set(state, { + data: Option.some(thread), + status: "live", + error: Option.none(), + }); + yield* Queue.offer(persistence, thread); + }); + + const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { + yield* SubscriptionRef.set(state, { + data: Option.none(), + status: "deleted", + error: Option.none(), + }); + yield* cache.removeThread(environmentId, threadId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the cached thread.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + ), + ); + }); + + const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( + item: OrchestrationThreadStreamItem, + ) { + if (item.kind === "snapshot") { + yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); + yield* setThread(item.snapshot.thread); + return; + } + + const sequence = yield* SubscriptionRef.get(lastSequence); + if (item.event.sequence <= sequence) { + return; + } + yield* SubscriptionRef.set(lastSequence, item.event.sequence); + + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data)) { + if (item.event.type === "thread.deleted") { + yield* setDeleted(); + } + return; + } + const result = applyThreadDetailEvent(current.data.value, item.event, limits); + if (result.kind === "updated") { + yield* setThread(result.thread); + } else if (result.kind === "deleted") { + yield* setDeleted(); + } + }); + + yield* SubscriptionRef.changes(supervisor.state).pipe( + Stream.runForEach((connectionState) => { + switch (connectionProjectionPhase(connectionState)) { + case "synchronizing": + return setSynchronizing; + case "disconnected": + return setDisconnected; + case "ready": + return setReady; + } + }), + Effect.forkScoped, + ); + + yield* setSynchronizing; + yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, { threadId }).pipe( + Stream.runForEach(applyItem), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) ? Effect.interrupt : setStreamError(cause), + ), + Effect.forkScoped, + ); + + yield* Effect.addFinalizer(() => + SubscriptionRef.get(state).pipe( + Effect.flatMap((current) => + Option.match(current.data, { + onNone: () => Effect.void, + onSome: persist, + }), + ), + ), + ); + + return state; +}); + +export function threadStateChanges( + environmentId: EnvironmentIdType, + threadId: ThreadIdType, + options?: { + readonly limits?: ThreadDetailRetentionLimits; + }, +) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeEnvironmentThreadState(threadId, options?.limits ?? DEFAULT_THREAD_DETAIL_LIMITS).pipe( + Effect.map(SubscriptionRef.changes), + ), + ), + ); +} + +function threadAtomKey(environmentId: EnvironmentIdType, threadId: ThreadIdType): string { + return `${environmentId}\u0000${threadId}`; +} + +function parseThreadAtomKey(key: string): { + readonly environmentId: EnvironmentIdType; + readonly threadId: ThreadIdType; +} { + const separator = key.indexOf("\u0000"); + if (separator < 0) { + throw new Error("Invalid environment thread atom key."); + } + return { + environmentId: EnvironmentId.make(key.slice(0, separator)), + threadId: ThreadId.make(key.slice(separator + 1)), + }; +} + +export function createEnvironmentThreadStateAtoms( + runtime: Atom.AtomRuntime, +) { + const family = Atom.family((key: string) => { + const { environmentId, threadId } = parseThreadAtomKey(key); + return runtime.atom(threadStateChanges(environmentId, threadId), { + initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, + }); + }); + + return { + stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => + family(threadAtomKey(environmentId, threadId)), + }; +} + +export * from "./archivedThreads.ts"; +export * from "./checkpointDiff.ts"; +export * from "./composerPathSearch.ts"; +export * from "./threadCommands.ts"; +export * from "./threadDetail.ts"; +export * from "./threadReducer.ts"; +export * from "./threadShell.ts"; diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts new file mode 100644 index 00000000000..bee8c3884a3 --- /dev/null +++ b/packages/client-runtime/src/state/vcs.ts @@ -0,0 +1,70 @@ +import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts"; +import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import * as Stream from "effect/Stream"; +import { Atom } from "effect/unstable/reactivity"; + +import { + createEnvironmentRpcMutation, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; + +export function createVcsEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + listRefs: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-refs", + tag: WS_METHODS.vcsListRefs, + staleTimeMs: 5_000, + }), + status: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:vcs:status", + subscribe: (input: EnvironmentRpcInput) => + subscribe(WS_METHODS.subscribeVcsStatus, input).pipe( + Stream.mapAccum( + () => null as VcsStatusResult | null, + (current, event) => { + const next = applyGitStatusStreamEvent(current, event); + return [next, [next]] as const; + }, + ), + ), + }), + pull: createEnvironmentRpcMutation(runtime, { + label: "environment-data:vcs:pull", + tag: WS_METHODS.vcsPull, + }), + refreshStatus: createEnvironmentRpcMutation(runtime, { + label: "environment-data:vcs:refresh-status", + tag: WS_METHODS.vcsRefreshStatus, + }), + createWorktree: createEnvironmentRpcMutation(runtime, { + label: "environment-data:vcs:create-worktree", + tag: WS_METHODS.vcsCreateWorktree, + }), + removeWorktree: createEnvironmentRpcMutation(runtime, { + label: "environment-data:vcs:remove-worktree", + tag: WS_METHODS.vcsRemoveWorktree, + }), + createRef: createEnvironmentRpcMutation(runtime, { + label: "environment-data:vcs:create-ref", + tag: WS_METHODS.vcsCreateRef, + }), + switchRef: createEnvironmentRpcMutation(runtime, { + label: "environment-data:vcs:switch-ref", + tag: WS_METHODS.vcsSwitchRef, + }), + init: createEnvironmentRpcMutation(runtime, { + label: "environment-data:vcs:init", + tag: WS_METHODS.vcsInit, + }), + }; +} + +export * from "./gitActions.ts"; +export * from "./vcsAction.ts"; +export * from "./vcsRef.ts"; +export * from "./vcsStatus.ts"; diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts new file mode 100644 index 00000000000..96689fb624f --- /dev/null +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -0,0 +1,120 @@ +import { EnvironmentId, type GitActionProgressEvent } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + applyVcsActionProgressEvent, + EMPTY_VCS_ACTION_STATE, + getVcsActionTargetKey, +} from "./vcsAction.ts"; + +const actionId = "action-123"; +const action = "commit_push" as const; +const cwd = "/repo"; + +function progress(event: T): T { + return event; +} + +describe("vcsActionState", () => { + it("projects phase and hook progress without owning the async operation", () => { + const phase = applyVcsActionProgressEvent( + EMPTY_VCS_ACTION_STATE, + progress({ + actionId, + action, + cwd, + kind: "phase_started", + phase: "commit", + label: "Committing...", + }), + ); + const hook = applyVcsActionProgressEvent( + phase, + progress({ + actionId, + action, + cwd, + kind: "hook_started", + hookName: "post-commit", + }), + ); + const output = applyVcsActionProgressEvent( + hook, + progress({ + actionId, + action, + cwd, + kind: "hook_output", + hookName: "post-commit", + stream: "stdout", + text: "hook output", + }), + ); + const finished = applyVcsActionProgressEvent( + output, + progress({ + actionId, + action, + cwd, + kind: "hook_finished", + hookName: "post-commit", + exitCode: 0, + durationMs: 12, + }), + ); + + expect(phase).toMatchObject({ + isRunning: true, + currentLabel: "Committing...", + currentPhaseLabel: "Committing...", + }); + expect(output).toMatchObject({ + currentLabel: "Running post-commit...", + hookName: "post-commit", + lastOutputLine: "hook output", + }); + expect(finished).toMatchObject({ + currentLabel: "Committing...", + hookName: null, + lastOutputLine: null, + }); + }); + + it("retains a terminal action error for presentation", () => { + const failed = applyVcsActionProgressEvent( + EMPTY_VCS_ACTION_STATE, + progress({ + actionId, + action, + cwd, + kind: "action_failed", + phase: null, + message: "Push failed.", + }), + ); + + expect(failed).toMatchObject({ + isRunning: false, + operation: "run_change_request", + actionId, + action, + error: "Push failed.", + }); + }); + + it("keys presentation state only when the environment and repository are known", () => { + expect( + getVcsActionTargetKey({ + environmentId: EnvironmentId.make("environment-1"), + cwd, + }), + ).toBe(`environment-1:${cwd}`); + expect(getVcsActionTargetKey({ environmentId: null, cwd })).toBeNull(); + expect( + getVcsActionTargetKey({ + environmentId: EnvironmentId.make("environment-1"), + cwd: null, + }), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts new file mode 100644 index 00000000000..5fcdd23428f --- /dev/null +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -0,0 +1,157 @@ +import type { EnvironmentId, GitActionProgressEvent, GitStackedAction } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { Atom } from "effect/unstable/reactivity"; + +export type VcsActionOperation = + | "refresh_status" + | "run_change_request" + | "pull" + | "switch_ref" + | "create_ref" + | "create_worktree" + | "init"; + +export interface VcsActionState { + readonly isRunning: boolean; + readonly operation: VcsActionOperation | null; + readonly actionId: string | null; + readonly action: GitStackedAction | null; + readonly currentLabel: string | null; + readonly currentPhaseLabel: string | null; + readonly hookName: string | null; + readonly lastOutputLine: string | null; + readonly phaseStartedAtMs: number | null; + readonly hookStartedAtMs: number | null; + readonly error: string | null; +} + +export interface VcsActionTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} + +export const EMPTY_VCS_ACTION_STATE = Object.freeze({ + isRunning: false, + operation: null, + actionId: null, + action: null, + currentLabel: null, + currentPhaseLabel: null, + hookName: null, + lastOutputLine: null, + phaseStartedAtMs: null, + hookStartedAtMs: null, + error: null, +}); + +const nowMs = (): number => DateTime.toEpochMillis(DateTime.nowUnsafe()); + +export const vcsActionStateAtom = Atom.family((key: string) => { + return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`vcs-action:${key}`), + ); +}); + +export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("vcs-action:null"), +); + +export function getVcsActionTargetKey(target: VcsActionTarget): string | null { + if (target.environmentId === null || target.cwd === null) { + return null; + } + return `${target.environmentId}:${target.cwd}`; +} + +export function applyVcsActionProgressEvent( + current: VcsActionState, + event: GitActionProgressEvent, +): VcsActionState { + const now = nowMs(); + + switch (event.kind) { + case "action_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + phaseStartedAtMs: now, + hookStartedAtMs: null, + hookName: null, + lastOutputLine: null, + error: null, + }; + case "phase_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: event.label, + currentPhaseLabel: event.label, + phaseStartedAtMs: now, + hookStartedAtMs: null, + hookName: null, + lastOutputLine: null, + error: null, + }; + case "hook_started": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: `Running ${event.hookName}...`, + hookName: event.hookName, + hookStartedAtMs: now, + lastOutputLine: null, + error: null, + }; + case "hook_output": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + lastOutputLine: event.text, + error: null, + }; + case "hook_finished": + return { + ...current, + isRunning: true, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + currentLabel: current.currentPhaseLabel, + hookName: null, + hookStartedAtMs: null, + lastOutputLine: null, + error: null, + }; + case "action_finished": + return { + ...current, + isRunning: false, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + error: null, + }; + case "action_failed": + return { + ...EMPTY_VCS_ACTION_STATE, + actionId: event.actionId, + action: event.action, + operation: "run_change_request", + error: event.message, + }; + } +} diff --git a/packages/client-runtime/src/state/vcsRef.ts b/packages/client-runtime/src/state/vcsRef.ts new file mode 100644 index 00000000000..5e879356d2f --- /dev/null +++ b/packages/client-runtime/src/state/vcsRef.ts @@ -0,0 +1,9 @@ +import type { EnvironmentId, VcsRef as ContractVcsRef } from "@t3tools/contracts"; + +export interface VcsRefTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query?: string | null; +} + +export type VcsRef = ContractVcsRef; diff --git a/packages/client-runtime/src/state/vcsStatus.ts b/packages/client-runtime/src/state/vcsStatus.ts new file mode 100644 index 00000000000..0a301fa86f3 --- /dev/null +++ b/packages/client-runtime/src/state/vcsStatus.ts @@ -0,0 +1,6 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface VcsStatusTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; +} diff --git a/packages/client-runtime/src/terminalSessionState.test.ts b/packages/client-runtime/src/terminalSessionState.test.ts deleted file mode 100644 index 401536915de..00000000000 --- a/packages/client-runtime/src/terminalSessionState.test.ts +++ /dev/null @@ -1,558 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { - EnvironmentId, - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, - ThreadId, -} from "@t3tools/contracts"; - -import { - createTerminalSessionManager, - getKnownTerminalSessionListFilter, - knownTerminalSessionsAtom, - runningTerminalIdsAtom, - terminalSessionStateAtom, - type KnownTerminalSessionTarget, -} from "./terminalSessionState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), - terminalId: "term-1", -} as const; - -const BASE_SNAPSHOT: TerminalSessionSnapshot = { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - history: "hello", - exitCode: null, - exitSignal: null, - label: "Terminal 1", - updatedAt: "2026-04-01T00:00:00.000Z", -}; - -type TerminalSessionManager = ReturnType; - -function applyAttachEvents( - manager: TerminalSessionManager, - target: KnownTerminalSessionTarget, - events: ReadonlyArray, -): void { - manager.attach({ - environmentId: target.environmentId, - terminal: { - threadId: target.threadId, - terminalId: target.terminalId, - }, - client: { - terminal: { - attach: (_input, listener) => { - events.forEach(listener); - return () => undefined; - }, - }, - }, - })(); -} - -function applyMetadataEvents( - manager: TerminalSessionManager, - environmentId: EnvironmentId, - events: ReadonlyArray, -): void { - manager.subscribeMetadata({ - environmentId, - client: { - terminal: { - onMetadata: (listener) => { - events.forEach(listener); - return () => undefined; - }, - }, - }, - })(); -} - -describe("createTerminalSessionManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("hydrates from started snapshots and appends output events", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: " world", - }, - ]); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - summary: null, - buffer: "hello world", - status: "running", - error: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - }); - }); - - it("caps retained output", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - maxBufferBytes: 5, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "abcdef", - }, - ]); - - expect(manager.getSnapshot(TARGET).buffer).toBe("bcdef"); - }); - - it("caps retained output by utf-8 byte length", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - maxBufferBytes: 4, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "🙂🙂", - }, - ]); - - expect(manager.getSnapshot(TARGET).buffer).toBe("🙂"); - }); - - it("invalidates one environment without clearing others", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - const otherTarget = { - environmentId: EnvironmentId.make("env-remote"), - threadId: ThreadId.make("thread-1"), - terminalId: "term-1", - } as const; - - for (const target of [TARGET, otherTarget]) { - applyAttachEvents(manager, target, [ - { - type: "output", - threadId: target.threadId, - terminalId: target.terminalId, - data: target.environmentId, - }, - ]); - } - - manager.invalidateEnvironment(TARGET.environmentId); - - expect(manager.getSnapshot(TARGET).buffer).toBe(""); - expect(manager.getSnapshot(otherTarget).buffer).toBe("env-remote"); - }); - - it("lists known sessions for a thread ordered by terminal id (numeric-aware)", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "snapshot", - terminals: [ - { - threadId: TARGET.threadId, - terminalId: "term-10", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 125, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: false, - label: "Terminal 10", - }, - { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:00.000Z", - hasRunningSubprocess: false, - label: "Terminal 1", - }, - { - threadId: TARGET.threadId, - terminalId: "term-2", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 124, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:02.000Z", - hasRunningSubprocess: false, - label: "Terminal 2", - }, - ], - }, - ]); - - expect( - manager - .listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }) - .map((session) => session.target.terminalId), - ).toEqual(["term-1", "term-2", "term-10"]); - }); - - it("drops known sessions when an environment is invalidated", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "output", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - data: "hello", - }, - ]); - - manager.invalidateEnvironment(TARGET.environmentId); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toEqual([]); - }); - - it("removes closed sessions from the known-session index while keeping local closed state", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: false, - label: "Terminal 1", - }, - }, - ]); - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "closed", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "remove", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toEqual([]); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - buffer: "hello", - status: "closed", - summary: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - }); - }); - - it("clears locally retained closed state on reset", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - { - type: "closed", - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - }, - ]); - - manager.reset(); - - expect(manager.getSnapshot(TARGET)).toEqual({ - summary: null, - buffer: "", - status: "closed", - error: null, - hasRunningSubprocess: false, - updatedAt: null, - version: 0, - }); - }); - - it("syncs snapshots returned from open calls immediately", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: { - ...BASE_SNAPSHOT, - history: "prompt$ ", - updatedAt: "2026-04-01T00:00:03.000Z", - }, - }, - ]); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - buffer: "prompt$ ", - status: "running", - updatedAt: "2026-04-01T00:00:03.000Z", - }); - }); - - it("syncs authoritative metadata snapshots and removes missing environment terminals", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - ]); - applyAttachEvents( - manager, - { - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - terminalId: "term-2", - }, - [ - { - type: "snapshot", - snapshot: { - ...BASE_SNAPSHOT, - terminalId: "term-2", - label: "Terminal 2", - updatedAt: "2026-04-01T00:00:02.000Z", - }, - }, - ], - ); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "snapshot", - terminals: [ - { - threadId: TARGET.threadId, - terminalId: "term-2", - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: true, - label: "Terminal 2", - }, - ], - }, - ]); - - expect( - manager.listSessions({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }), - ).toMatchObject([ - { - target: { - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - terminalId: "term-2", - }, - state: { - summary: { - terminalId: "term-2", - cwd: "/repo", - }, - hasRunningSubprocess: true, - }, - }, - ]); - }); - - it("updates listed session metadata when existing session activity changes", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: false, - label: "Terminal 1", - }, - }, - ]); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: "2026-04-01T00:00:05.000Z", - hasRunningSubprocess: true, - label: "Terminal 1", - }, - }, - ]); - - expect( - manager.listSessions({ environmentId: TARGET.environmentId, threadId: TARGET.threadId }), - ).toMatchObject([ - { - state: { - hasRunningSubprocess: true, - }, - }, - ]); - }); - - it("derives session atoms from structurally equal target objects", () => { - const manager = createTerminalSessionManager({ - getRegistry: () => atomRegistry, - }); - - applyMetadataEvents(manager, TARGET.environmentId, [ - { - type: "upsert", - terminal: { - threadId: TARGET.threadId, - terminalId: TARGET.terminalId, - cwd: "/repo", - worktreePath: null, - status: "running", - pid: 123, - exitCode: null, - exitSignal: null, - updatedAt: BASE_SNAPSHOT.updatedAt, - hasRunningSubprocess: true, - label: "Terminal 1", - }, - }, - ]); - applyAttachEvents(manager, TARGET, [ - { - type: "snapshot", - snapshot: BASE_SNAPSHOT, - }, - ]); - - const equalTarget = { ...TARGET }; - const filter = getKnownTerminalSessionListFilter({ - environmentId: TARGET.environmentId, - threadId: TARGET.threadId, - }); - expect(filter).not.toBeNull(); - if (filter === null) { - return; - } - - expect(atomRegistry.get(terminalSessionStateAtom(equalTarget))).toMatchObject({ - buffer: BASE_SNAPSHOT.history, - hasRunningSubprocess: true, - }); - expect( - atomRegistry.get(knownTerminalSessionsAtom({ ...filter })).map((session) => session.target), - ).toEqual([TARGET]); - expect(atomRegistry.get(runningTerminalIdsAtom({ ...filter }))).toEqual([TARGET.terminalId]); - }); -}); diff --git a/packages/client-runtime/src/terminalSessionState.ts b/packages/client-runtime/src/terminalSessionState.ts deleted file mode 100644 index 668ac343a49..00000000000 --- a/packages/client-runtime/src/terminalSessionState.ts +++ /dev/null @@ -1,605 +0,0 @@ -import type { - TerminalAttachStreamEvent, - TerminalMetadataStreamEvent, - TerminalSessionSnapshot, - TerminalSummary, - EnvironmentId, -} from "@t3tools/contracts"; -import { ThreadId, type TerminalAttachInput } from "@t3tools/contracts"; -import * as Arr from "effect/Array"; -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Result from "effect/Result"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -export interface TerminalSessionState { - readonly summary: TerminalSummary | null; - readonly buffer: string; - readonly status: TerminalSessionSnapshot["status"] | "closed"; - readonly error: string | null; - readonly hasRunningSubprocess: boolean; - readonly updatedAt: string | null; - readonly version: number; -} - -export interface TerminalBufferState { - readonly buffer: string; - readonly status: TerminalSessionSnapshot["status"] | "closed"; - readonly error: string | null; - readonly updatedAt: string | null; - readonly version: number; -} - -export interface TerminalSessionTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadId | null; - readonly terminalId: string | null; -} - -export interface KnownTerminalSessionTarget { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId; - readonly terminalId: string; -} - -export interface KnownTerminalSession { - readonly target: KnownTerminalSessionTarget; - readonly state: TerminalSessionState; -} - -export interface KnownTerminalMetadata { - readonly target: KnownTerminalSessionTarget; - readonly summary: TerminalSummary; -} - -export interface TerminalSessionListFilter { - readonly environmentId: EnvironmentId | null; - readonly threadId?: ThreadId | null; - readonly terminalId?: string | null; -} - -export interface KnownTerminalSessionListFilter { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadId | null; - readonly terminalId: string | null; -} - -export interface TerminalSessionManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly maxBufferBytes?: number; -} - -export interface TerminalMetadataClient { - readonly terminal: { - readonly onMetadata: ( - listener: (event: TerminalMetadataStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; -} - -export interface TerminalAttachClient { - readonly terminal: { - readonly attach: ( - input: TerminalAttachInput, - listener: (event: TerminalAttachStreamEvent) => void, - options?: { readonly onResubscribe?: () => void }, - ) => () => void; - }; -} - -export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze({ - buffer: "", - status: "closed", - error: null, - updatedAt: null, - version: 0, -}); - -export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze({ - summary: null, - buffer: "", - status: "closed", - error: null, - hasRunningSubprocess: false, - updatedAt: null, - version: 0, -}); - -const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze>([]); -const EMPTY_TERMINAL_ID_LIST = Object.freeze>([]); -const DEFAULT_MAX_BUFFER_BYTES = 512 * 1024; -const knownTerminalMetadataEnvironmentIds = new Set(); -const knownTerminalBufferTargets = new Map(); -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder(); -const terminalIdOrder = Order.make( - (left, right) => left.localeCompare(right, undefined, { numeric: true }) as -1 | 0 | 1, -); -const knownTerminalSessionOrder = Order.mapInput( - terminalIdOrder, - (session: KnownTerminalSession) => session.target.terminalId, -); - -export const terminalSessionMetadataAtom = Atom.family((environmentId: EnvironmentId) => { - knownTerminalMetadataEnvironmentIds.add(environmentId); - return Atom.make>({}).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:metadata:${environmentId}`), - ); -}); - -export const terminalSessionBufferAtom = Atom.family((target: KnownTerminalSessionTarget) => { - const key = keyFromKnownTarget(target); - knownTerminalBufferTargets.set(key, target); - return Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:buffer:${key}`), - ); -}); - -export const EMPTY_TERMINAL_BUFFER_ATOM = Atom.make(EMPTY_TERMINAL_BUFFER_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:buffer:null"), -); - -export const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:state:null"), -); - -export const EMPTY_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make(EMPTY_KNOWN_TERMINAL_SESSIONS).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:known:null"), -); - -export const EMPTY_TERMINAL_ID_LIST_ATOM = Atom.make(EMPTY_TERMINAL_ID_LIST).pipe( - Atom.keepAlive, - Atom.withLabel("terminal-session:running-terminal-ids:null"), -); - -export function getKnownTerminalSessionTarget( - target: TerminalSessionTarget, -): KnownTerminalSessionTarget | null { - if (target.environmentId === null || target.threadId === null || target.terminalId === null) { - return null; - } - - return { - environmentId: target.environmentId, - threadId: target.threadId, - terminalId: target.terminalId, - }; -} - -export function getKnownTerminalSessionListFilter( - filter: TerminalSessionListFilter, -): KnownTerminalSessionListFilter | null { - if (filter.environmentId === null) { - return null; - } - - return { - environmentId: filter.environmentId, - threadId: filter.threadId ?? null, - terminalId: filter.terminalId ?? null, - }; -} - -function knownTargetFromSummary( - environmentId: EnvironmentId, - summary: TerminalSummary, -): KnownTerminalSessionTarget { - return { - environmentId, - threadId: ThreadId.make(summary.threadId), - terminalId: summary.terminalId, - }; -} - -function keyFromKnownTarget(target: KnownTerminalSessionTarget): string { - return `${target.environmentId}:${target.threadId}:${target.terminalId}`; -} - -function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { - if (maxBufferBytes <= 0) { - return ""; - } - - const encoded = textEncoder.encode(buffer); - if (encoded.byteLength <= maxBufferBytes) { - return buffer; - } - - let start = encoded.byteLength - maxBufferBytes; - while (start < encoded.length) { - const byte = encoded[start]; - if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { - break; - } - start += 1; - } - - return textDecoder.decode(encoded.subarray(start)); -} - -function bufferFromSnapshot( - snapshot: TerminalSessionSnapshot, - maxBufferBytes: number, -): TerminalBufferState { - return { - buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), - status: snapshot.status, - error: null, - updatedAt: snapshot.updatedAt, - version: 1, - }; -} - -function latestTimestamp(left: string | null, right: string | null): string | null { - if (left === null) return right; - if (right === null) return left; - return Date.parse(left) >= Date.parse(right) ? left : right; -} - -function combineSessionState( - summary: TerminalSummary | null, - buffer: TerminalBufferState, -): TerminalSessionState { - return { - summary, - buffer: buffer.buffer, - status: summary?.status ?? buffer.status, - error: buffer.error, - hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, - updatedAt: latestTimestamp(summary?.updatedAt ?? null, buffer.updatedAt), - version: buffer.version, - }; -} - -function listKnownSessionsFromMetadata( - metadata: Record, - getBuffer: (target: KnownTerminalSessionTarget) => TerminalBufferState, - filter?: Partial, -): ReadonlyArray { - return pipe( - Object.values(metadata), - Arr.filterMap(({ target, summary }) => { - if (filter?.environmentId && target.environmentId !== filter.environmentId) { - return Result.failVoid; - } - if (filter?.threadId && target.threadId !== filter.threadId) { - return Result.failVoid; - } - if (filter?.terminalId && target.terminalId !== filter.terminalId) { - return Result.failVoid; - } - return Result.succeed({ - target, - state: combineSessionState(summary, getBuffer(target)), - }); - }), - Arr.sort(knownTerminalSessionOrder), - ); -} - -export const terminalSessionStateAtom = Atom.family((target: KnownTerminalSessionTarget) => - Atom.make((get) => { - const targetKey = keyFromKnownTarget(target); - return combineSessionState( - get(terminalSessionMetadataAtom(target.environmentId))[targetKey]?.summary ?? null, - get(terminalSessionBufferAtom(target)), - ); - }).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:state:${keyFromKnownTarget(target)}`)), -); - -export const knownTerminalSessionsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => - Atom.make((get) => - listKnownSessionsFromMetadata( - get(terminalSessionMetadataAtom(filter.environmentId)), - (target) => get(terminalSessionBufferAtom(target)), - { - environmentId: filter.environmentId, - ...(filter.threadId !== null ? { threadId: filter.threadId } : {}), - ...(filter.terminalId !== null ? { terminalId: filter.terminalId } : {}), - }, - ), - ).pipe(Atom.keepAlive, Atom.withLabel(`terminal-session:known:${JSON.stringify(filter)}`)), -); - -export const runningTerminalIdsAtom = Atom.family((filter: KnownTerminalSessionListFilter) => - Atom.make((get) => { - return pipe( - Object.values(get(terminalSessionMetadataAtom(filter.environmentId))), - Arr.filterMap((entry) => - entry.target.environmentId === filter.environmentId && - (filter.threadId === null || entry.target.threadId === filter.threadId) && - (filter.terminalId === null || entry.target.terminalId === filter.terminalId) && - entry.summary.hasRunningSubprocess - ? Result.succeed(entry.target.terminalId) - : Result.failVoid, - ), - Arr.sort(Order.String), - ); - }).pipe( - Atom.keepAlive, - Atom.withLabel(`terminal-session:running-terminal-ids:${JSON.stringify(filter)}`), - ), -); - -export function createTerminalSessionManager(config: TerminalSessionManagerConfig) { - const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; - - function getMetadata(environmentId: EnvironmentId): Record { - return config.getRegistry().get(terminalSessionMetadataAtom(environmentId)); - } - - function setMetadata( - environmentId: EnvironmentId, - next: Record, - ): void { - config.getRegistry().set(terminalSessionMetadataAtom(environmentId), next); - } - - function getBuffer(target: KnownTerminalSessionTarget): TerminalBufferState { - return config.getRegistry().get(terminalSessionBufferAtom(target)); - } - - function setBuffer(target: KnownTerminalSessionTarget, next: TerminalBufferState): void { - config.getRegistry().set(terminalSessionBufferAtom(target), next); - } - - function getSnapshot(target: TerminalSessionTarget): TerminalSessionState { - const knownTarget = getKnownTerminalSessionTarget(target); - if (knownTarget === null) { - return EMPTY_TERMINAL_SESSION_STATE; - } - - return combineSessionState( - getMetadata(knownTarget.environmentId)[keyFromKnownTarget(knownTarget)]?.summary ?? null, - getBuffer(knownTarget), - ); - } - - function syncSnapshot( - target: Pick, - snapshot: TerminalSessionSnapshot, - ): void { - const knownTarget = getKnownTerminalSessionTarget({ - environmentId: target.environmentId, - threadId: ThreadId.make(snapshot.threadId), - terminalId: snapshot.terminalId, - }); - if (knownTarget === null) { - return; - } - - setBuffer(knownTarget, bufferFromSnapshot(snapshot, maxBufferBytes)); - } - - function applyMetadataEvent( - target: Pick, - event: TerminalMetadataStreamEvent, - ): void { - const environmentId = target.environmentId; - if (environmentId === null) { - return; - } - - if (event.type === "snapshot") { - const retainedKeys = new Set(); - const next = { ...getMetadata(environmentId) }; - - for (const terminal of event.terminals) { - const knownTarget = knownTargetFromSummary(environmentId, terminal); - const targetKey = keyFromKnownTarget(knownTarget); - retainedKeys.add(targetKey); - next[targetKey] = { - target: knownTarget, - summary: terminal, - }; - } - - for (const key of Object.keys(next)) { - if (!retainedKeys.has(key)) { - delete next[key]; - } - } - - setMetadata(environmentId, next); - return; - } - - if (event.type === "upsert") { - const knownTarget = knownTargetFromSummary(environmentId, event.terminal); - const targetKey = keyFromKnownTarget(knownTarget); - setMetadata(environmentId, { - ...getMetadata(environmentId), - [targetKey]: { - target: knownTarget, - summary: event.terminal, - }, - }); - return; - } - - const knownTarget = getKnownTerminalSessionTarget({ - environmentId, - threadId: ThreadId.make(event.threadId), - terminalId: event.terminalId, - }); - if (knownTarget === null) { - return; - } - - const next = { ...getMetadata(environmentId) }; - delete next[keyFromKnownTarget(knownTarget)]; - setMetadata(environmentId, next); - } - - function applyAttachEvent( - target: Pick, - event: TerminalAttachStreamEvent, - ): void { - if (event.type === "snapshot") { - syncSnapshot(target, event.snapshot); - return; - } - - const knownTarget = getKnownTerminalSessionTarget({ - environmentId: target.environmentId, - threadId: ThreadId.make(event.threadId), - terminalId: event.terminalId, - }); - if (knownTarget === null) { - return; - } - - const current = getBuffer(knownTarget); - switch (event.type) { - case "restarted": - setBuffer(knownTarget, bufferFromSnapshot(event.snapshot, maxBufferBytes)); - return; - case "output": - setBuffer(knownTarget, { - ...current, - buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), - status: current.status === "closed" ? "running" : current.status, - error: null, - version: current.version + 1, - }); - return; - case "cleared": - setBuffer(knownTarget, { - ...current, - buffer: "", - error: null, - version: current.version + 1, - }); - return; - case "exited": - setBuffer(knownTarget, { - ...current, - status: "exited", - error: null, - version: current.version + 1, - }); - return; - case "closed": - setBuffer(knownTarget, { - ...current, - status: "closed", - error: null, - version: current.version + 1, - }); - return; - case "error": - setBuffer(knownTarget, { - ...current, - status: "error", - error: event.message, - version: current.version + 1, - }); - return; - case "activity": - return; - } - } - - function invalidate(target?: TerminalSessionTarget): void { - if (target) { - const knownTarget = getKnownTerminalSessionTarget(target); - if (knownTarget !== null) { - const targetKey = keyFromKnownTarget(knownTarget); - const next = { ...getMetadata(knownTarget.environmentId) }; - delete next[targetKey]; - setMetadata(knownTarget.environmentId, next); - setBuffer(knownTarget, EMPTY_TERMINAL_BUFFER_STATE); - } - return; - } - - for (const environmentId of knownTerminalMetadataEnvironmentIds) { - setMetadata(environmentId, {}); - } - knownTerminalMetadataEnvironmentIds.clear(); - for (const target of knownTerminalBufferTargets.values()) { - setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); - } - knownTerminalBufferTargets.clear(); - } - - function invalidateEnvironment(environmentId: EnvironmentId): void { - setMetadata(environmentId, {}); - knownTerminalMetadataEnvironmentIds.delete(environmentId); - - const prefix = `${environmentId}:`; - for (const [key, target] of knownTerminalBufferTargets) { - if (key.startsWith(prefix)) { - setBuffer(target, EMPTY_TERMINAL_BUFFER_STATE); - } - } - } - - function reset(): void { - invalidate(); - } - - function listSessions( - filter?: Partial, - ): ReadonlyArray { - if (filter?.environmentId) { - return listKnownSessionsFromMetadata(getMetadata(filter.environmentId), getBuffer, filter); - } - - return pipe( - knownTerminalMetadataEnvironmentIds, - Arr.fromIterable, - Arr.flatMap((environmentId) => - listKnownSessionsFromMetadata(getMetadata(environmentId), getBuffer, filter), - ), - ); - } - - function subscribeMetadata(input: { - readonly environmentId: EnvironmentId; - readonly client: TerminalMetadataClient; - readonly options?: { readonly onResubscribe?: () => void }; - }): () => void { - return input.client.terminal.onMetadata( - (event) => applyMetadataEvent({ environmentId: input.environmentId }, event), - input.options, - ); - } - - function attach(input: { - readonly environmentId: EnvironmentId; - readonly client: TerminalAttachClient; - readonly terminal: TerminalAttachInput; - readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void; - readonly onEvent?: (event: TerminalAttachStreamEvent) => void; - readonly options?: { readonly onResubscribe?: () => void }; - }): () => void { - return input.client.terminal.attach( - input.terminal, - (event) => { - applyAttachEvent({ environmentId: input.environmentId }, event); - input.onEvent?.(event); - if (event.type === "snapshot") { - input.onSnapshot?.(event.snapshot); - } - }, - input.options, - ); - } - - return { - attach, - getSnapshot, - invalidate, - invalidateEnvironment, - listSessions, - subscribeMetadata, - reset, - }; -} diff --git a/packages/client-runtime/src/threadDetailState.test.ts b/packages/client-runtime/src/threadDetailState.test.ts deleted file mode 100644 index df482ce1f6b..00000000000 --- a/packages/client-runtime/src/threadDetailState.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - EventId, - EnvironmentId, - MessageId, - ProjectId, - ProviderInstanceId, - ThreadId, - TurnId, - type OrchestrationThread, - type OrchestrationThreadStreamItem, -} from "@t3tools/contracts"; - -import { createThreadDetailManager, type ThreadDetailClient } from "./threadDetailState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const baseEventFields = { - eventId: EventId.make("event-1"), - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, -} as const; - -const BASE_THREAD: OrchestrationThread = { - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-1"), - title: "Test Thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - deletedAt: null, - messages: [], - proposedPlans: [], - activities: [], - checkpoints: [], - session: null, -}; - -const TARGET = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), -} as const; - -function createMockClient(): { - client: ThreadDetailClient; - listeners: Set<(event: OrchestrationThreadStreamItem) => void>; - emit: (event: OrchestrationThreadStreamItem) => void; -} { - const listeners = new Set<(event: OrchestrationThreadStreamItem) => void>(); - const client: ThreadDetailClient = { - subscribeThread: vi.fn((_input, listener: (event: OrchestrationThreadStreamItem) => void) => - registerListener(listeners, listener), - ), - }; - - return { - client, - listeners, - emit: (event) => { - for (const listener of listeners) { - listener(event); - } - }, - }; -} - -describe("createThreadDetailManager", () => { - afterEach(() => { - vi.useRealTimers(); - resetAtomRegistry(); - }); - - it("starts in a pending state when watching", () => { - const { client } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - manager.watch(TARGET, client); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: true, - isDeleted: false, - }); - }); - - it("applies snapshots and incremental events", () => { - const { client, emit } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - - emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - - emit({ - kind: "event", - event: { - ...baseEventFields, - sequence: 2, - occurredAt: "2026-04-01T01:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.message-sent", - payload: { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - messageId: MessageId.make("message-1"), - role: "assistant", - text: "hello", - streaming: false, - createdAt: "2026-04-01T01:00:00.000Z", - updatedAt: "2026-04-01T01:00:00.000Z", - }, - } as any, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: { - ...BASE_THREAD, - updatedAt: "2026-04-01T01:00:00.000Z", - latestTurn: { - turnId: TurnId.make("turn-1"), - state: "completed", - requestedAt: "2026-04-01T01:00:00.000Z", - startedAt: "2026-04-01T01:00:00.000Z", - completedAt: "2026-04-01T01:00:00.000Z", - assistantMessageId: MessageId.make("message-1"), - }, - messages: [ - { - id: MessageId.make("message-1"), - role: "assistant", - text: "hello", - turnId: TurnId.make("turn-1"), - streaming: false, - createdAt: "2026-04-01T01:00:00.000Z", - updatedAt: "2026-04-01T01:00:00.000Z", - }, - ], - }, - error: null, - isPending: false, - isDeleted: false, - }); - - release(); - }); - - it("marks threads as deleted when the stream deletes them", () => { - const { client, emit } = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - - emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - emit({ - kind: "event", - event: { - ...baseEventFields, - sequence: 3, - occurredAt: "2026-04-01T01:10:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.deleted", - payload: { - threadId: ThreadId.make("thread-1"), - deletedAt: "2026-04-01T01:10:00.000Z", - }, - } as any, - }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - error: null, - isPending: false, - isDeleted: true, - }); - - release(); - }); - - it("waits for delayed client registration when subscribeClientChanges is configured", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: (environmentId) => clients.get(environmentId)?.client ?? null, - getClientIdentity: (environmentId) => (clients.has(environmentId) ? environmentId : null), - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET).isPending).toBe(true); - - const mock = createMockClient(); - clients.set("env-local", mock); - for (const listener of connectionListeners) { - listener(); - } - - mock.emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: BASE_THREAD, - }, - }); - - expect(manager.getSnapshot(TARGET).data?.id).toBe(ThreadId.make("thread-1")); - - release(); - }); - - it("evicts idle subscriptions after the configured ttl", () => { - vi.useFakeTimers(); - const mock = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - retention: { - idleTtlMs: 60_000, - maxRetainedEntries: 10, - }, - }); - - const release = manager.watch(TARGET); - expect(mock.listeners.size).toBe(1); - - release(); - expect(mock.listeners.size).toBe(1); - - vi.advanceTimersByTime(60_000); - expect(mock.listeners.size).toBe(0); - }); - - it("keeps non-idle threads warm when the retention policy says to", () => { - vi.useFakeTimers(); - const mock = createMockClient(); - const manager = createThreadDetailManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - retention: { - idleTtlMs: 60_000, - maxRetainedEntries: 10, - shouldKeepWarm: (_target, state) => state.data?.session?.status === "running", - }, - }); - - const release = manager.watch(TARGET); - mock.emit({ - kind: "snapshot", - snapshot: { - snapshotSequence: 1, - thread: { - ...BASE_THREAD, - session: { - threadId: ThreadId.make("thread-1"), - status: "running", - providerName: "codex", - runtimeMode: "full-access", - activeTurnId: TurnId.make("turn-1"), - lastError: null, - updatedAt: "2026-04-01T00:10:00.000Z", - }, - }, - }, - }); - - release(); - vi.advanceTimersByTime(60_000); - - expect(mock.listeners.size).toBe(1); - manager.reset(); - expect(mock.listeners.size).toBe(0); - }); -}); diff --git a/packages/client-runtime/src/threadDetailState.ts b/packages/client-runtime/src/threadDetailState.ts deleted file mode 100644 index d8c5cc4add4..00000000000 --- a/packages/client-runtime/src/threadDetailState.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { pipe } from "effect/Function"; -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; -import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import type { - OrchestrationThread, - OrchestrationThreadStreamItem, - EnvironmentId, - ThreadId as ThreadIdType, -} from "@t3tools/contracts"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { - DEFAULT_THREAD_DETAIL_LIMITS, - applyThreadDetailEvent, - type ThreadDetailRetentionLimits, -} from "./threadDetailReducer.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface ThreadDetailState { - readonly data: OrchestrationThread | null; - readonly error: string | null; - readonly isPending: boolean; - readonly isDeleted: boolean; -} - -export interface ThreadDetailTarget { - readonly environmentId: EnvironmentId | null; - readonly threadId: ThreadIdType | null; -} - -export type ThreadDetailClient = Pick; - -export interface ThreadDetailRetentionPolicy { - readonly idleTtlMs: number; - readonly maxRetainedEntries: number; - readonly shouldKeepWarm?: ( - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - state: ThreadDetailState, - ) => boolean; -} - -interface ThreadDetailEntry { - readonly target: { - readonly environmentId: EnvironmentId; - readonly threadId: ThreadIdType; - }; - watcherCount: number; - retainCount: number; - teardown: () => void; - lastAccessedAt: number; - evictionFiber: Fiber.Fiber | null; -} - -const NOOP: () => void = () => undefined; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -function clearEntryEviction(entry: ThreadDetailEntry): void { - if (entry.evictionFiber !== null) { - Effect.runFork(Fiber.interrupt(entry.evictionFiber)); - entry.evictionFiber = null; - } -} - -export const EMPTY_THREAD_DETAIL_STATE = Object.freeze({ - data: null, - error: null, - isPending: false, - isDeleted: false, -}); - -const INITIAL_THREAD_DETAIL_STATE = Object.freeze({ - data: null, - error: null, - isPending: true, - isDeleted: false, -}); - -const knownThreadDetailKeys = new Set(); - -export const threadDetailStateAtom = Atom.family((key: string) => { - knownThreadDetailKeys.add(key); - return Atom.make(INITIAL_THREAD_DETAIL_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`thread-detail:${key}`), - ); -}); - -export const EMPTY_THREAD_DETAIL_ATOM = Atom.make(EMPTY_THREAD_DETAIL_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("thread-detail:null"), -); - -export function getThreadDetailTargetKey(target: ThreadDetailTarget): string | null { - if (target.environmentId === null || target.threadId === null) { - return null; - } - - return `${target.environmentId}:${target.threadId}`; -} - -export interface ThreadDetailManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => ThreadDetailClient | null; - readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly limits?: ThreadDetailRetentionLimits; - readonly retention?: ThreadDetailRetentionPolicy; -} - -export function createThreadDetailManager(config: ThreadDetailManagerConfig) { - const entries = new Map(); - - function getSnapshot(target: ThreadDetailTarget): ThreadDetailState { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey === null) { - return EMPTY_THREAD_DETAIL_STATE; - } - - return config.getRegistry().get(threadDetailStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: ThreadDetailState): void { - config.getRegistry().set(threadDetailStateAtom(targetKey), nextState); - reconcileRetention(targetKey); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(threadDetailStateAtom(targetKey)); - setState(targetKey, { - ...current, - error: null, - isPending: true, - }); - } - - function setData(targetKey: string, thread: OrchestrationThread): void { - setState(targetKey, { - data: thread, - error: null, - isPending: false, - isDeleted: false, - }); - } - - function setDeleted(targetKey: string): void { - setState(targetKey, { - data: null, - error: null, - isPending: false, - isDeleted: true, - }); - } - - function shouldKeepWarm(entry: ThreadDetailEntry): boolean { - return config.retention?.shouldKeepWarm?.(entry.target, getSnapshot(entry.target)) ?? false; - } - - function disposeEntry(targetKey: string): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - clearEntryEviction(entry); - entry.teardown(); - entries.delete(targetKey); - } - - function evictIdleEntriesToCapacity(): void { - const retention = config.retention; - if (!retention || entries.size <= retention.maxRetainedEntries) { - return; - } - - const idleEntries = pipe( - Arr.fromIterable(entries), - Arr.filter( - ([, entry]) => - entry.watcherCount === 0 && entry.retainCount === 0 && !shouldKeepWarm(entry), - ), - Arr.sortWith(([, e]) => e.lastAccessedAt, Order.Number), - ); - - for (const [targetKey] of idleEntries) { - if (entries.size <= retention.maxRetainedEntries) { - return; - } - disposeEntry(targetKey); - } - } - - function scheduleEviction(targetKey: string, entry: ThreadDetailEntry): void { - const retention = config.retention; - clearEntryEviction(entry); - - if (!retention) { - disposeEntry(targetKey); - return; - } - - if (retention.idleTtlMs <= 0) { - disposeEntry(targetKey); - return; - } - - entry.evictionFiber = Effect.runFork( - Effect.sleep(Duration.millis(retention.idleTtlMs)).pipe( - Effect.andThen( - Effect.sync(() => { - const current = entries.get(targetKey); - if (!current) { - return; - } - - current.evictionFiber = null; - if (current.watcherCount > 0 || current.retainCount > 0 || shouldKeepWarm(current)) { - return; - } - - disposeEntry(targetKey); - }), - ), - ), - ); - } - - function reconcileRetention(targetKey: string): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - clearEntryEviction(entry); - if (entry.watcherCount > 0 || entry.retainCount > 0 || shouldKeepWarm(entry)) { - return; - } - - scheduleEviction(targetKey, entry); - evictIdleEntriesToCapacity(); - } - - function applyStreamItem( - targetKey: string, - item: OrchestrationThreadStreamItem, - threadId: ThreadIdType, - ): void { - if (item.kind === "snapshot") { - setData(targetKey, item.snapshot.thread); - return; - } - - const current = getSnapshot({ - environmentId: entries.get(targetKey)?.target.environmentId ?? null, - threadId, - }).data; - - if (current === null) { - if (item.event.type === "thread.deleted") { - setDeleted(targetKey); - } - return; - } - - const result = applyThreadDetailEvent( - current, - item.event, - config.limits ?? DEFAULT_THREAD_DETAIL_LIMITS, - ); - - if (result.kind === "updated") { - setData(targetKey, result.thread); - return; - } - - if (result.kind === "deleted") { - setDeleted(targetKey); - } - } - - function subscribeStream( - targetKey: string, - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - client: ThreadDetailClient, - ): () => void { - markPending(targetKey); - return client.subscribeThread( - { threadId: target.threadId }, - (item) => applyStreamItem(targetKey, item, target.threadId), - { - onResubscribe: () => markPending(targetKey), - }, - ); - } - - function createDynamicSubscription( - targetKey: string, - target: { readonly environmentId: EnvironmentId; readonly threadId: ThreadIdType }, - ): () => void { - let currentIdentity: string | null = null; - let currentUnsub = NOOP; - - const sync = () => { - const client = config.getClient(target.environmentId); - const identity = client - ? (config.getClientIdentity?.(target.environmentId) ?? target.environmentId) - : null; - - if (!client || identity === null) { - if (currentIdentity !== null) { - currentUnsub(); - currentUnsub = NOOP; - currentIdentity = null; - } - markPending(targetKey); - return; - } - - if (currentIdentity === identity) { - return; - } - - currentUnsub(); - currentIdentity = identity; - currentUnsub = subscribeStream(targetKey, target, client); - }; - - const unsubChanges = config.subscribeClientChanges!(sync); - sync(); - - return () => { - unsubChanges(); - currentUnsub(); - }; - } - - function acquire( - target: ThreadDetailTarget, - kind: "watcher" | "retain", - client?: ThreadDetailClient, - ): () => void { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey === null || target.environmentId === null || target.threadId === null) { - return NOOP; - } - - const existing = entries.get(targetKey); - if (existing) { - clearEntryEviction(existing); - existing.lastAccessedAt = nowMs(); - if (kind === "watcher") { - existing.watcherCount += 1; - } else { - existing.retainCount += 1; - } - return () => release(targetKey, kind); - } - - let teardown: () => void; - const resolvedTarget = { - environmentId: target.environmentId, - threadId: target.threadId, - }; - - if (client) { - teardown = subscribeStream(targetKey, resolvedTarget, client); - } else if (config.subscribeClientChanges) { - teardown = createDynamicSubscription(targetKey, resolvedTarget); - } else { - const resolved = config.getClient(target.environmentId); - if (!resolved) { - return NOOP; - } - teardown = subscribeStream(targetKey, resolvedTarget, resolved); - } - - entries.set(targetKey, { - target: resolvedTarget, - watcherCount: kind === "watcher" ? 1 : 0, - retainCount: kind === "retain" ? 1 : 0, - teardown, - lastAccessedAt: nowMs(), - evictionFiber: null, - }); - evictIdleEntriesToCapacity(); - return () => release(targetKey, kind); - } - - function release(targetKey: string, kind: "watcher" | "retain"): void { - const entry = entries.get(targetKey); - if (!entry) { - return; - } - - if (kind === "watcher") { - entry.watcherCount = Math.max(0, entry.watcherCount - 1); - } else { - entry.retainCount = Math.max(0, entry.retainCount - 1); - } - entry.lastAccessedAt = nowMs(); - reconcileRetention(targetKey); - } - - function watch(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { - return acquire(target, "watcher", client); - } - - function retain(target: ThreadDetailTarget, client?: ThreadDetailClient): () => void { - return acquire(target, "retain", client); - } - - function invalidate(target?: ThreadDetailTarget): void { - if (target) { - const targetKey = getThreadDetailTargetKey(target); - if (targetKey !== null) { - disposeEntry(targetKey); - config.getRegistry().set(threadDetailStateAtom(targetKey), EMPTY_THREAD_DETAIL_STATE); - } - return; - } - - for (const targetKey of entries.keys()) { - disposeEntry(targetKey); - } - for (const key of knownThreadDetailKeys) { - config.getRegistry().set(threadDetailStateAtom(key), EMPTY_THREAD_DETAIL_STATE); - } - } - - function reset(): void { - invalidate(); - } - - return { - watch, - retain, - getSnapshot, - invalidate, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsActionState.test.ts b/packages/client-runtime/src/vcsActionState.test.ts deleted file mode 100644 index f653b26b34f..00000000000 --- a/packages/client-runtime/src/vcsActionState.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - EnvironmentId, - type GitActionProgressEvent, - type GitRunStackedActionResult, - type VcsCreateRefResult, - type VcsCreateWorktreeResult, - type VcsPullResult, - type VcsStatusResult, - type VcsSwitchRefResult, -} from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - type VcsActionClient, - createVcsActionManager, - EMPTY_VCS_ACTION_STATE, -} from "./vcsActionState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; - -const BASE_STATUS: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/test", - hasWorkingTreeChanges: true, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -function createPhaseStartedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "phase_started", - phase: "commit", - label: "Committing...", - }; -} - -function createHookStartedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_started", - hookName: "post-commit", - }; -} - -function createHookOutputEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_output", - hookName: "post-commit", - stream: "stdout", - text: "hook output", - }; -} - -function createHookFinishedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "hook_finished", - hookName: "post-commit", - exitCode: 0, - durationMs: 12, - }; -} - -function createActionFinishedEvent(): Extract { - return { - actionId: "action-123", - cwd: "/repo", - action: "commit_push", - kind: "action_finished", - result: { - action: "commit_push", - branch: { status: "skipped_not_requested" }, - commit: { status: "created", commitSha: "abc123", subject: "Test commit" }, - push: { - status: "pushed", - branch: "feature/test", - upstreamBranch: "origin/feature/test", - }, - pr: { status: "skipped_not_requested" }, - toast: { - title: "Done", - description: "Action finished", - cta: { kind: "none" }, - }, - } satisfies GitRunStackedActionResult, - }; -} - -function createMockClient() { - const refreshDeferred = createDeferred(); - const pullDeferred = createDeferred(); - const switchRefDeferred = createDeferred(); - const createRefDeferred = createDeferred(); - const createWorktreeDeferred = createDeferred(); - const initDeferred = createDeferred(); - const runChangeRequestDeferred = createDeferred(); - let runChangeRequestProgressListener: ((event: GitActionProgressEvent) => void) | null = null; - - const client: VcsActionClient = { - refreshStatus: vi.fn(() => refreshDeferred.promise), - pull: vi.fn(() => pullDeferred.promise), - switchRef: vi.fn(() => switchRefDeferred.promise), - createRef: vi.fn(() => createRefDeferred.promise), - createWorktree: vi.fn(() => createWorktreeDeferred.promise), - init: vi.fn(() => initDeferred.promise), - runChangeRequest: vi.fn((_, options) => { - runChangeRequestProgressListener = options?.onProgress ?? null; - return runChangeRequestDeferred.promise; - }), - }; - - return { - client, - refreshDeferred, - pullDeferred, - switchRefDeferred, - createRefDeferred, - createWorktreeDeferred, - initDeferred, - runChangeRequestDeferred, - emitProgress(event: GitActionProgressEvent) { - runChangeRequestProgressListener?.(event); - }, - }; -} - -describe("createVcsActionManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("tracks refreshStatus progress and clears state on success", async () => { - const mock = createMockClient(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.refreshStatus(TARGET, mock.client); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: true, - operation: "refresh_status", - currentLabel: "Refreshing source control status", - error: null, - }); - - mock.refreshDeferred.resolve(BASE_STATUS); - - await expect(promise).resolves.toEqual(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); - - it("tracks runChangeRequest progress events", async () => { - const mock = createMockClient(); - const onProgress = vi.fn(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - getActionId: () => "action-123", - }); - - const promise = manager.runChangeRequest( - TARGET, - { action: "commit_push", commitMessage: "Test commit" }, - { client: mock.client, gitStatus: BASE_STATUS, onProgress }, - ); - - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: true, - operation: "run_change_request", - actionId: "action-123", - currentLabel: "Committing...", - error: null, - }); - - mock.emitProgress(createPhaseStartedEvent()); - expect(manager.getSnapshot(TARGET).currentLabel).toBe("Committing..."); - expect(onProgress).toHaveBeenLastCalledWith(createPhaseStartedEvent()); - - mock.emitProgress(createHookStartedEvent()); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - currentLabel: "Running post-commit...", - hookName: "post-commit", - isRunning: true, - }); - - mock.emitProgress(createHookOutputEvent()); - expect(manager.getSnapshot(TARGET).lastOutputLine).toBe("hook output"); - - mock.emitProgress(createHookFinishedEvent()); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - currentLabel: "Committing...", - hookName: null, - lastOutputLine: null, - }); - - const result = createActionFinishedEvent().result; - mock.runChangeRequestDeferred.resolve(result); - - await expect(promise).resolves.toEqual(result); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); - - it("stores the error when an operation fails", async () => { - const mock = createMockClient(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.pull(TARGET, mock.client); - - mock.pullDeferred.reject(new Error("Pull failed.")); - - await expect(promise).rejects.toThrow("Pull failed."); - expect(manager.getSnapshot(TARGET)).toMatchObject({ - isRunning: false, - operation: "pull", - currentLabel: null, - error: "Pull failed.", - }); - }); - - it("invalidates after successful mutations but not refreshStatus", async () => { - const mock = createMockClient(); - const onInvalidate = vi.fn(); - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - onInvalidate, - }); - - const refreshPromise = manager.refreshStatus(TARGET, mock.client); - mock.refreshDeferred.resolve(BASE_STATUS); - await expect(refreshPromise).resolves.toEqual(BASE_STATUS); - expect(onInvalidate).not.toHaveBeenCalled(); - - const pullPromise = manager.pull(TARGET, mock.client); - const pullResult: VcsPullResult = { - status: "skipped_up_to_date", - refName: "main", - upstreamRef: null, - }; - mock.pullDeferred.resolve(pullResult); - await expect(pullPromise).resolves.toEqual(pullResult); - expect(onInvalidate).toHaveBeenCalledWith(TARGET); - }); - - it("returns null when no client is available", async () => { - const manager = createVcsActionManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - await expect(manager.switchRef(TARGET, { refName: "main" })).resolves.toBeNull(); - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_ACTION_STATE); - }); -}); diff --git a/packages/client-runtime/src/vcsActionState.ts b/packages/client-runtime/src/vcsActionState.ts deleted file mode 100644 index 5ff545b4596..00000000000 --- a/packages/client-runtime/src/vcsActionState.ts +++ /dev/null @@ -1,458 +0,0 @@ -import type { - GitActionProgressEvent, - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStackedAction, - EnvironmentId, - VcsCreateRefInput, - VcsCreateRefResult, - VcsCreateWorktreeInput, - VcsCreateWorktreeResult, - VcsPullInput, - VcsPullResult, - VcsStatusResult, - VcsSwitchRefInput, - VcsSwitchRefResult, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; - -import { buildGitActionProgressStages } from "./gitActions.ts"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export type VcsActionOperation = - | "refresh_status" - | "run_change_request" - | "pull" - | "switch_ref" - | "create_ref" - | "create_worktree" - | "init"; - -export interface VcsActionState { - readonly isRunning: boolean; - readonly operation: VcsActionOperation | null; - readonly actionId: string | null; - readonly action: GitStackedAction | null; - readonly currentLabel: string | null; - readonly currentPhaseLabel: string | null; - readonly hookName: string | null; - readonly lastOutputLine: string | null; - readonly phaseStartedAtMs: number | null; - readonly hookStartedAtMs: number | null; - readonly error: string | null; -} - -export interface VcsActionTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export type VcsActionClient = Pick< - WsRpcClient["vcs"], - "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init" -> & { - readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"]; -}; - -export const EMPTY_VCS_ACTION_STATE = Object.freeze({ - isRunning: false, - operation: null, - actionId: null, - action: null, - currentLabel: null, - currentPhaseLabel: null, - hookName: null, - lastOutputLine: null, - phaseStartedAtMs: null, - hookStartedAtMs: null, - error: null, -}); - -const knownVcsActionKeys = new Set(); -let nextGeneratedActionId = 0; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -export const vcsActionStateAtom = Atom.family((key: string) => { - knownVcsActionKeys.add(key); - return Atom.make(EMPTY_VCS_ACTION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`vcs-action:${key}`), - ); -}); - -export const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-action:null"), -); - -export function getVcsActionTargetKey(target: VcsActionTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - return `${target.environmentId}:${target.cwd}`; -} - -export function applyVcsActionProgressEvent( - current: VcsActionState, - event: GitActionProgressEvent, -): VcsActionState { - const now = nowMs(); - - switch (event.kind) { - case "action_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - phaseStartedAtMs: now, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - error: null, - }; - case "phase_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: event.label, - currentPhaseLabel: event.label, - phaseStartedAtMs: now, - hookStartedAtMs: null, - hookName: null, - lastOutputLine: null, - error: null, - }; - case "hook_started": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: `Running ${event.hookName}...`, - hookName: event.hookName, - hookStartedAtMs: now, - lastOutputLine: null, - error: null, - }; - case "hook_output": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - lastOutputLine: event.text, - error: null, - }; - case "hook_finished": - return { - ...current, - isRunning: true, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - currentLabel: current.currentPhaseLabel, - hookName: null, - hookStartedAtMs: null, - lastOutputLine: null, - error: null, - }; - case "action_finished": - return { - ...current, - isRunning: false, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - error: null, - }; - case "action_failed": - return { - ...EMPTY_VCS_ACTION_STATE, - actionId: event.actionId, - action: event.action, - operation: "run_change_request", - error: event.message, - }; - } -} - -export interface VcsActionManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => VcsActionClient | null; - readonly getActionId?: () => string; - readonly onInvalidate?: (target: VcsActionTarget) => void | Promise; -} - -export function createVcsActionManager(config: VcsActionManagerConfig) { - function setState(targetKey: string, nextState: VcsActionState): void { - config.getRegistry().set(vcsActionStateAtom(targetKey), nextState); - } - - function startOperation( - targetKey: string, - input: { - readonly operation: VcsActionOperation; - readonly actionId?: string; - readonly action?: GitStackedAction; - readonly label: string; - }, - ): void { - setState(targetKey, { - isRunning: true, - operation: input.operation, - actionId: input.actionId ?? null, - action: input.action ?? null, - currentLabel: input.label, - currentPhaseLabel: input.label, - hookName: null, - lastOutputLine: null, - phaseStartedAtMs: nowMs(), - hookStartedAtMs: null, - error: null, - }); - } - - function finishOperation(targetKey: string): void { - setState(targetKey, EMPTY_VCS_ACTION_STATE); - } - - function failOperation( - targetKey: string, - error: unknown, - input: { - readonly operation: VcsActionOperation; - readonly actionId?: string; - readonly action?: GitStackedAction; - }, - ): void { - setState(targetKey, { - ...EMPTY_VCS_ACTION_STATE, - operation: input.operation, - actionId: input.actionId ?? null, - action: input.action ?? null, - error: error instanceof Error ? error.message : "Source control action failed.", - }); - } - - async function runOperation( - target: VcsActionTarget, - input: { - readonly operation: VcsActionOperation; - readonly label: string; - readonly actionId?: string; - readonly action?: GitStackedAction; - readonly client?: VcsActionClient | undefined; - readonly invalidateOnSuccess?: boolean; - readonly execute: (client: VcsActionClient) => Promise; - }, - ): Promise { - const targetKey = getVcsActionTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return null; - } - - const resolved = input.client ?? config.getClient(target.environmentId); - if (!resolved) { - return null; - } - - startOperation(targetKey, input); - try { - const result = await input.execute(resolved); - finishOperation(targetKey); - if (input.invalidateOnSuccess ?? true) { - await config.onInvalidate?.(target); - } - return result; - } catch (error) { - failOperation(targetKey, error, input); - throw error; - } - } - - function getSnapshot(target: VcsActionTarget): VcsActionState { - const targetKey = getVcsActionTargetKey(target); - if (targetKey === null) { - return EMPTY_VCS_ACTION_STATE; - } - - return config.getRegistry().get(vcsActionStateAtom(targetKey)); - } - - async function refreshStatus( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly quiet?: boolean }, - ): Promise> | null> { - if (options?.quiet) { - if (target.environmentId === null || target.cwd === null) { - return null; - } - const resolved = client ?? config.getClient(target.environmentId); - return resolved ? resolved.refreshStatus({ cwd: target.cwd }) : null; - } - - return runOperation(target, { - operation: "refresh_status", - label: "Refreshing source control status", - client, - invalidateOnSuccess: false, - execute: (resolved) => resolved.refreshStatus({ cwd: target.cwd! }), - }); - } - - async function pull( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "pull", - label: options?.label ?? "Pulling latest changes", - client, - execute: (resolved) => resolved.pull({ cwd: target.cwd! } satisfies VcsPullInput), - }); - } - - async function switchRef( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "switch_ref", - label: options?.label ?? "Switching branch", - client, - execute: (resolved) => resolved.switchRef({ cwd: target.cwd!, ...input }), - }); - } - - async function createRef( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "create_ref", - label: options?.label ?? "Creating branch", - client, - execute: (resolved) => resolved.createRef({ cwd: target.cwd!, ...input }), - }); - } - - async function createWorktree( - target: VcsActionTarget, - input: Omit, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise { - return runOperation(target, { - operation: "create_worktree", - label: options?.label ?? "Creating worktree", - client, - execute: (resolved) => resolved.createWorktree({ cwd: target.cwd!, ...input }), - }); - } - - async function init( - target: VcsActionTarget, - client?: VcsActionClient, - options?: { readonly label?: string }, - ): Promise> | null> { - return runOperation(target, { - operation: "init", - label: options?.label ?? "Initializing repository", - client, - execute: (resolved) => resolved.init({ cwd: target.cwd! }), - }); - } - - async function runChangeRequest( - target: VcsActionTarget, - input: Omit & { readonly actionId?: string }, - options?: { - readonly client?: VcsActionClient; - readonly gitStatus?: VcsStatusResult | null; - readonly onProgress?: (event: GitActionProgressEvent) => void; - }, - ): Promise { - const actionId = - input.actionId ?? - config.getActionId?.() ?? - `vcs-action-${nowMs()}-${++nextGeneratedActionId}`; - const targetKey = getVcsActionTargetKey(target); - - return runOperation(target, { - operation: "run_change_request", - label: - buildGitActionProgressStages({ - action: input.action, - hasCustomCommitMessage: Boolean(input.commitMessage?.trim()), - hasWorkingTreeChanges: options?.gitStatus?.hasWorkingTreeChanges ?? false, - featureBranch: input.featureBranch ?? false, - shouldPushBeforePr: - input.action === "create_pr" && - (!(options?.gitStatus?.hasUpstream ?? false) || - (options?.gitStatus?.aheadCount ?? 0) > 0), - })[0] ?? "Running source control action", - actionId, - action: input.action, - client: options?.client, - execute: async (resolved) => { - const result = await resolved.runChangeRequest( - { - cwd: target.cwd!, - actionId, - ...input, - }, - { - onProgress: (event) => { - if (targetKey !== null) { - const current = getSnapshot(target); - setState(targetKey, applyVcsActionProgressEvent(current, event)); - } - options?.onProgress?.(event); - }, - }, - ); - return result; - }, - }); - } - - function reset(target?: VcsActionTarget): void { - if (target) { - const targetKey = getVcsActionTargetKey(target); - if (targetKey !== null) { - setState(targetKey, EMPTY_VCS_ACTION_STATE); - } - return; - } - - for (const key of knownVcsActionKeys) { - setState(key, EMPTY_VCS_ACTION_STATE); - } - } - - return { - getSnapshot, - refreshStatus, - pull, - switchRef, - createRef, - createWorktree, - init, - runChangeRequest, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsRefState.test.ts b/packages/client-runtime/src/vcsRefState.test.ts deleted file mode 100644 index 3e58c0b5ac0..00000000000 --- a/packages/client-runtime/src/vcsRefState.test.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { - createVcsRefManager, - EMPTY_VCS_REF_STATE, - vcsRefStateAtom, - type VcsRefClient, -} from "./vcsRefState.ts"; - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -const noop = () => undefined; - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; - -const FIRST_PAGE: VcsListRefsResult = { - refs: [ - { name: "main", current: true, isDefault: true, worktreePath: null }, - { name: "feature/a", current: false, isDefault: false, worktreePath: null }, - ], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: 2, - totalCount: 3, -}; - -const SECOND_PAGE: VcsListRefsResult = { - refs: [{ name: "feature/b", current: false, isDefault: false, worktreePath: null }], - isRepo: true, - hasPrimaryRemote: true, - nextCursor: null, - totalCount: 3, -}; - -function createMockClient() { - const listRefs = vi.fn(async (input: Parameters[0]) => { - if (input.query === "feature") { - return { - ...FIRST_PAGE, - refs: FIRST_PAGE.refs.filter((branch) => branch.name.includes("feature")), - nextCursor: null, - totalCount: 2, - } satisfies VcsListRefsResult; - } - - if (input.cursor === 2) { - return SECOND_PAGE; - } - - return FIRST_PAGE; - }); - - return { - client: { listRefs } satisfies VcsRefClient, - listRefs, - }; -} - -function deferred() { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -describe("createVcsRefManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - it("loads the first page and stores it in atom state", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const promise = manager.load(TARGET, mock.client, { limit: 100 }); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - isPending: true, - error: null, - }); - - await expect(promise).resolves.toEqual(FIRST_PAGE); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: false, - error: null, - }); - expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); - }); - - it("loads the next page and appends refs", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - await manager.load(TARGET, mock.client); - const next = await manager.loadNext(TARGET, mock.client); - - expect(next).toEqual({ - ...SECOND_PAGE, - refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], - }); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: { - ...SECOND_PAGE, - refs: [...FIRST_PAGE.refs, ...SECOND_PAGE.refs], - }, - isPending: false, - error: null, - }); - }); - - it("keeps cached refs visible while refreshing", async () => { - const nextLoad = deferred(); - let callCount = 0; - const listRefs = vi.fn((async () => { - callCount += 1; - return callCount === 1 ? FIRST_PAGE : nextLoad.promise; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - await manager.load(TARGET, client); - - const refresh = manager.load(TARGET, client); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: true, - error: null, - }); - - nextLoad.resolve(SECOND_PAGE); - await expect(refresh).resolves.toEqual(SECOND_PAGE); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: SECOND_PAGE, - isPending: false, - error: null, - }); - }); - - it("preserves loaded pages during first-page revalidation", async () => { - const refreshedFirstPage: VcsListRefsResult = { - ...FIRST_PAGE, - refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], - nextCursor: 1, - totalCount: 3, - }; - let callCount = 0; - const listRefs = vi.fn((async (input) => { - callCount += 1; - if (input.cursor === 2) { - return SECOND_PAGE; - } - return callCount === 1 ? FIRST_PAGE : refreshedFirstPage; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - await manager.load(TARGET, client); - await manager.loadNext(TARGET, client); - const beforeRefresh = manager.getSnapshot(TARGET).data; - expect(beforeRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); - - await manager.load(TARGET, client, { preserveLoadedRefs: true }); - - const afterRefresh = manager.getSnapshot(TARGET).data; - expect(afterRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]); - expect(afterRefresh?.nextCursor).toBeNull(); - }); - - it("stores query-specific state independently", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - const queriedTarget = { ...TARGET, query: "feature" } as const; - const queried = await manager.load(queriedTarget, mock.client); - - expect(queried?.refs.map((branch) => branch.name)).toEqual(["feature/a"]); - expect(manager.getSnapshot(TARGET).data).toBeNull(); - expect(manager.getSnapshot(queriedTarget).data?.refs.map((branch) => branch.name)).toEqual([ - "feature/a", - ]); - }); - - it("returns cached data when no client is available", async () => { - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - atomRegistry.set(vcsRefStateAtom("env-local:/repo:"), { - data: FIRST_PAGE, - isPending: false, - error: null, - }); - - await expect(manager.load(TARGET)).resolves.toEqual(FIRST_PAGE); - }); - - it("resets state", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - await manager.load(TARGET, mock.client); - manager.reset(); - - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); - }); - - it("invalidates every query for a cwd scope", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - const queriedTarget = { ...TARGET, query: "feature" } as const; - - await manager.load(TARGET, mock.client); - await manager.load(queriedTarget, mock.client); - - manager.invalidateScope({ environmentId: TARGET.environmentId, cwd: TARGET.cwd }); - - expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE); - expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE); - }); - - it("invalidates target in-flight loads before they can write stale data", async () => { - const firstLoad = deferred(); - let callCount = 0; - const listRefs = vi.fn((async () => { - callCount += 1; - return callCount === 1 ? firstLoad.promise : SECOND_PAGE; - }) satisfies VcsRefClient["listRefs"]); - const client = { listRefs } satisfies VcsRefClient; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => client, - }); - - const staleLoad = manager.load(TARGET, client); - manager.invalidate(TARGET); - const freshLoad = manager.load(TARGET, client); - - expect(listRefs).toHaveBeenCalledTimes(2); - - firstLoad.resolve(FIRST_PAGE); - await expect(staleLoad).resolves.toEqual(FIRST_PAGE); - await expect(freshLoad).resolves.toEqual(SECOND_PAGE); - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - }); - - it("watches refs with a ref-counted client-change subscription", async () => { - const mock = createMockClient(); - let listener: () => void = noop; - const unsubscribe = vi.fn(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return unsubscribe; - }, - watchLimit: 100, - }); - - const firstUnwatch = manager.watch(TARGET); - const secondUnwatch = manager.watch(TARGET); - await Promise.resolve(); - - expect(mock.listRefs).toHaveBeenCalledTimes(1); - expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 }); - - listener(); - await Promise.resolve(); - expect(mock.listRefs).toHaveBeenCalledTimes(1); - - firstUnwatch(); - expect(unsubscribe).not.toHaveBeenCalled(); - secondUnwatch(); - expect(unsubscribe).toHaveBeenCalledTimes(1); - }); - - it("skips watched refresh while cached refs are fresh", async () => { - const mock = createMockClient(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - staleTimeMs: 60_000, - watchLimit: 100, - }); - - const firstUnwatch = manager.watch(TARGET); - await vi.waitFor(() => { - expect(manager.getSnapshot(TARGET).data).toEqual(FIRST_PAGE); - }); - firstUnwatch(); - - const secondUnwatch = manager.watch(TARGET); - await Promise.resolve(); - expect(mock.listRefs).toHaveBeenCalledTimes(1); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: FIRST_PAGE, - isPending: false, - error: null, - }); - - secondUnwatch(); - }); - - it("swallows watched refresh failures after storing error state", async () => { - const refreshError = new Error("backend unavailable"); - const listRefs = vi.fn(async () => { - throw refreshError; - }); - const onBackgroundError = vi.fn(); - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => ({ listRefs }), - onBackgroundError, - }); - - manager.watch(TARGET); - await Promise.resolve(); - await Promise.resolve(); - - await vi.waitFor(() => { - expect(manager.getSnapshot(TARGET)).toEqual({ - data: null, - isPending: false, - error: "backend unavailable", - }); - expect(onBackgroundError).toHaveBeenCalledWith(refreshError); - }); - }); - - it("starts a new watched refresh when the client is replaced while a load is in flight", async () => { - const firstLoad = deferred(); - const secondLoad = deferred(); - const firstListRefs = vi.fn(() => firstLoad.promise); - const secondListRefs = vi.fn(() => secondLoad.promise); - const firstClient = { listRefs: firstListRefs } satisfies VcsRefClient; - const secondClient = { listRefs: secondListRefs } satisfies VcsRefClient; - let currentClient: VcsRefClient = firstClient; - let listener: () => void = noop; - const manager = createVcsRefManager({ - getRegistry: () => atomRegistry, - getClient: () => currentClient, - subscribeClientChanges: (nextListener) => { - listener = nextListener; - return noop; - }, - }); - - manager.watch(TARGET); - await Promise.resolve(); - expect(firstListRefs).toHaveBeenCalledTimes(1); - - currentClient = secondClient; - listener(); - await Promise.resolve(); - expect(secondListRefs).toHaveBeenCalledTimes(1); - - secondLoad.resolve(SECOND_PAGE); - await secondLoad.promise; - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - - firstLoad.resolve(FIRST_PAGE); - await firstLoad.promise; - expect(manager.getSnapshot(TARGET).data).toEqual(SECOND_PAGE); - }); -}); diff --git a/packages/client-runtime/src/vcsRefState.ts b/packages/client-runtime/src/vcsRefState.ts deleted file mode 100644 index e414a5f3de5..00000000000 --- a/packages/client-runtime/src/vcsRefState.ts +++ /dev/null @@ -1,451 +0,0 @@ -import type { - EnvironmentId, - VcsListRefsInput, - VcsListRefsResult, - VcsRef as ContractVcsRef, -} from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Effect from "effect/Effect"; -import { Atom, type AtomRegistry, type AsyncResult } from "effect/unstable/reactivity"; - -import type { WsRpcClient } from "./wsRpcClient.ts"; - -export interface VcsRefTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query?: string | null; -} - -export interface VcsRefScope { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export interface VcsRefState { - readonly data: VcsListRefsResult | null; - readonly isPending: boolean; - readonly error: string | null; -} - -export type VcsRef = ContractVcsRef; -export type VcsRefClient = Pick; - -export const EMPTY_VCS_REF_STATE = Object.freeze({ - data: null, - isPending: false, - error: null, -}); - -const INITIAL_VCS_REF_STATE = Object.freeze({ - data: null, - isPending: true, - error: null, -}); - -const knownVcsRefKeys = new Set(); - -export const vcsRefStateAtom = Atom.family((key: string) => { - knownVcsRefKeys.add(key); - return Atom.make(EMPTY_VCS_REF_STATE).pipe(Atom.keepAlive, Atom.withLabel(`vcs-refs:${key}`)); -}); - -export const EMPTY_VCS_REF_ATOM = Atom.make(EMPTY_VCS_REF_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-refs:null"), -); - -function normalizeQuery(query: string | null | undefined): string { - return query?.trim() ?? ""; -} - -export function getVcsRefTargetKey(target: VcsRefTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - - return `${target.environmentId}:${target.cwd}:${normalizeQuery(target.query)}`; -} - -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : "Failed to load refs."; -} - -function mergeRefs( - previous: ReadonlyArray, - next: ReadonlyArray, -): ReadonlyArray { - const merged = new Map(); - for (const branch of previous) { - merged.set(branch.name, branch); - } - for (const branch of next) { - merged.set(branch.name, branch); - } - return [...merged.values()]; -} - -export interface VcsRefManagerConfig { - readonly getRegistry: () => AtomRegistry.AtomRegistry; - readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null; - readonly subscribeClientChanges?: (listener: () => void) => () => void; - readonly watchLimit?: number; - readonly staleTimeMs?: number; - readonly onBackgroundError?: (error: unknown) => void; -} - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -const NOOP: () => void = () => undefined; - -export function createVcsRefManager(config: VcsRefManagerConfig) { - const inFlight = new Map< - string, - { - readonly client: VcsRefClient; - readonly promise: Promise; - } - >(); - const loadVersions = new Map(); - const watched = new Map(); - const lastLoadedAt = new Map(); - const refreshTargets = new Map(); - const watchLoadOptions = - config.watchLimit === undefined - ? undefined - : { limit: config.watchLimit, preserveLoadedRefs: true }; - - const watchedRefreshAtom = Atom.family((targetKey: string) => - Atom.make(() => - Effect.promise(() => { - const target = refreshTargets.get(targetKey); - return target ? load(target, undefined, watchLoadOptions) : Promise.resolve(null); - }), - ).pipe( - Atom.swr({ - staleTime: config.staleTimeMs ?? 0, - revalidateOnMount: true, - }), - Atom.withLabel(`vcs-refs:watched-refresh:${targetKey}`), - ), - ); - - function getLoadVersion(targetKey: string): number { - return loadVersions.get(targetKey) ?? 0; - } - - function bumpLoadVersion(targetKey: string): number { - const next = getLoadVersion(targetKey) + 1; - loadVersions.set(targetKey, next); - return next; - } - - function getSnapshot(target: VcsRefTarget): VcsRefState { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null) { - return EMPTY_VCS_REF_STATE; - } - return config.getRegistry().get(vcsRefStateAtom(targetKey)); - } - - function setState(targetKey: string, nextState: VcsRefState): void { - config.getRegistry().set(vcsRefStateAtom(targetKey), nextState); - } - - function markPending(targetKey: string): void { - const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); - setState( - targetKey, - current.data === null ? INITIAL_VCS_REF_STATE : { ...current, isPending: true, error: null }, - ); - } - - function setData(targetKey: string, data: VcsListRefsResult): void { - lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis)); - setState(targetKey, { - data, - isPending: false, - error: null, - }); - } - - function setError(targetKey: string, error: unknown): void { - const current = config.getRegistry().get(vcsRefStateAtom(targetKey)); - setState(targetKey, { - data: current.data, - isPending: false, - error: toErrorMessage(error), - }); - } - - async function load( - target: VcsRefTarget, - client?: VcsRefClient, - options?: { - readonly cursor?: number; - readonly limit?: number; - readonly append?: boolean; - readonly preserveLoadedRefs?: boolean; - }, - ): Promise { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return null; - } - refreshTargets.set(targetKey, target); - - const resolved = client ?? config.getClient(target.environmentId); - if (!resolved) { - return getSnapshot(target).data; - } - - const inFlightKey = `${targetKey}:${options?.cursor ?? "start"}:${options?.append ? "append" : "replace"}`; - const existing = inFlight.get(inFlightKey); - if (existing && existing.client === resolved) { - return existing.promise; - } - - markPending(targetKey); - const loadVersion = bumpLoadVersion(targetKey); - - const current = getSnapshot(target).data; - const request: VcsListRefsInput = { - cwd: target.cwd, - ...(normalizeQuery(target.query).length > 0 ? { query: normalizeQuery(target.query) } : {}), - ...(options?.cursor !== undefined ? { cursor: options.cursor } : {}), - ...(options?.limit !== undefined ? { limit: options.limit } : {}), - }; - - const promise = resolved.listRefs(request).then( - (result) => { - const nextData = - options?.append && current - ? { - ...result, - refs: mergeRefs(current.refs, result.refs), - } - : options?.preserveLoadedRefs && current && current.refs.length > result.refs.length - ? { - ...result, - refs: mergeRefs(result.refs, current.refs), - nextCursor: current.nextCursor, - totalCount: Math.max(result.totalCount, current.totalCount), - } - : result; - if (getLoadVersion(targetKey) === loadVersion) { - setData(targetKey, nextData); - } - return nextData; - }, - (error) => { - if (getLoadVersion(targetKey) === loadVersion) { - setError(targetKey, error); - } - throw error; - }, - ); - - inFlight.set(inFlightKey, { client: resolved, promise }); - try { - return await promise; - } finally { - if (inFlight.get(inFlightKey)?.promise === promise) { - inFlight.delete(inFlightKey); - } - } - } - - function loadInBackground( - target: VcsRefTarget, - client: VcsRefClient, - options?: { - readonly cursor?: number; - readonly limit?: number; - readonly append?: boolean; - readonly preserveLoadedRefs?: boolean; - }, - ): void { - void load(target, client, options).catch((error: unknown) => { - config.onBackgroundError?.(error); - }); - } - - async function loadNext( - target: VcsRefTarget, - client?: VcsRefClient, - options?: { readonly limit?: number }, - ): Promise { - const current = getSnapshot(target).data; - if (!current?.nextCursor && current?.nextCursor !== 0) { - return current ?? null; - } - - return load(target, client, { - cursor: current.nextCursor, - append: true, - ...(options?.limit !== undefined ? { limit: options.limit } : {}), - }); - } - - function refreshWatchedTarget(targetKey: string, target: VcsRefTarget, client?: VcsRefClient) { - refreshTargets.set(targetKey, target); - - if (client || config.staleTimeMs === undefined) { - const resolved = - client ?? (target.environmentId ? config.getClient(target.environmentId) : null); - if (resolved) { - loadInBackground(target, resolved, watchLoadOptions); - } - return; - } - - const lastLoaded = lastLoadedAt.get(targetKey); - if ( - lastLoaded !== undefined && - Effect.runSync(Clock.currentTimeMillis) - lastLoaded < config.staleTimeMs - ) { - return; - } - - const result = config - .getRegistry() - .get(watchedRefreshAtom(targetKey)) as AsyncResult.AsyncResult< - VcsListRefsResult | null, - unknown - >; - if (result._tag === "Failure") { - config.onBackgroundError?.(result.cause); - } - } - - function watch(target: VcsRefTarget, client?: VcsRefClient): () => void { - const targetKey = getVcsRefTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - refreshWatchedTarget(targetKey, target, client); - teardown = NOOP; - } else if (config.subscribeClientChanges) { - let currentClient: VcsRefClient | null = null; - const sync = () => { - const resolved = config.getClient(target.environmentId!); - if (!resolved) { - currentClient = null; - return; - } - if (currentClient === resolved) { - return; - } - - const hadClient = currentClient !== null; - currentClient = resolved; - refreshWatchedTarget(targetKey, target, hadClient ? resolved : undefined); - }; - - const unsubscribe = config.subscribeClientChanges(sync); - sync(); - teardown = unsubscribe; - } else { - const resolved = config.getClient(target.environmentId); - if (!resolved) { - return NOOP; - } - refreshWatchedTarget(targetKey, target); - teardown = NOOP; - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) { - return; - } - - entry.refCount -= 1; - if (entry.refCount > 0) { - return; - } - - entry.teardown(); - watched.delete(targetKey); - } - - function invalidate(target?: VcsRefTarget): void { - if (target) { - const targetKey = getVcsRefTargetKey(target); - if (targetKey !== null) { - bumpLoadVersion(targetKey); - setState(targetKey, EMPTY_VCS_REF_STATE); - for (const key of inFlight.keys()) { - if (key.startsWith(`${targetKey}:`)) { - inFlight.delete(key); - } - } - } - return; - } - - for (const key of knownVcsRefKeys) { - bumpLoadVersion(key); - setState(key, EMPTY_VCS_REF_STATE); - } - inFlight.clear(); - } - - function invalidateScope(scope: VcsRefScope): void { - if (scope.environmentId === null || scope.cwd === null) { - return; - } - - const keyPrefix = `${scope.environmentId}:${scope.cwd}:`; - for (const key of knownVcsRefKeys) { - if (key.startsWith(keyPrefix)) { - bumpLoadVersion(key); - setState(key, EMPTY_VCS_REF_STATE); - } - } - - for (const key of inFlight.keys()) { - if (key.startsWith(keyPrefix)) { - inFlight.delete(key); - } - } - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - inFlight.clear(); - loadVersions.clear(); - lastLoadedAt.clear(); - refreshTargets.clear(); - invalidate(); - } - - return { - getSnapshot, - watch, - load, - loadNext, - invalidate, - invalidateScope, - reset, - }; -} diff --git a/packages/client-runtime/src/vcsStatusState.test.ts b/packages/client-runtime/src/vcsStatusState.test.ts deleted file mode 100644 index bb49abf34c6..00000000000 --- a/packages/client-runtime/src/vcsStatusState.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/unstable/reactivity"; -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; - -import { type VcsStatusClient, createVcsStatusManager } from "./vcsStatusState.ts"; - -/* ─── Test helpers ──────────────────────────────────────────────────── */ - -let atomRegistry = AtomRegistry.make(); - -function resetAtomRegistry() { - atomRegistry.dispose(); - atomRegistry = AtomRegistry.make(); -} - -function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; -} - -const BASE_STATUS: VcsStatusResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/push-status", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -function createMockClient(): { - client: VcsStatusClient; - listeners: Set<(event: VcsStatusResult) => void>; - emit: (event: VcsStatusResult) => void; -} { - const listeners = new Set<(event: VcsStatusResult) => void>(); - const client: VcsStatusClient = { - refreshStatus: vi.fn(async (input: { cwd: string }) => ({ - ...BASE_STATUS, - refName: `${input.cwd}-refreshed`, - })), - onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => - registerListener(listeners, listener), - ), - }; - return { - client, - listeners, - emit: (event) => { - for (const listener of listeners) listener(event); - }, - }; -} - -const PENDING = { data: null, error: null, cause: null, isPending: true }; -const EMPTY = { data: null, error: null, cause: null, isPending: false }; - -const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; -const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const; -const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const; - -/* ─── Tests ─────────────────────────────────────────────────────────── */ - -describe("createVcsStatusManager", () => { - afterEach(() => { - resetAtomRegistry(); - }); - - describe("with explicit client (no reconnection)", () => { - it("starts in a pending state when watching", () => { - const { client } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - manager.watch(TARGET, client); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - manager.reset(); - }); - - it("shares one subscription per cwd and updates the snapshot", () => { - const { client, listeners, emit } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const releaseA = manager.watch(TARGET, client); - const releaseB = manager.watch(TARGET, client); - - expect(client.onStatus).toHaveBeenCalledOnce(); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - - emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - releaseA(); - expect(listeners.size).toBe(1); - - releaseB(); - expect(listeners.size).toBe(0); - }); - - it("refreshes via unary RPC without restarting the stream", async () => { - const { client, emit } = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET, client); - emit(BASE_STATUS); - - const refreshed = await manager.refresh(TARGET, client); - - expect(client.onStatus).toHaveBeenCalledOnce(); - expect(client.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); - - // Snapshot still reflects stream data, not the refresh response - expect(manager.getSnapshot(TARGET)).toEqual({ - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - release(); - }); - - it("keeps subscriptions isolated by environment when cwds match", () => { - const local = createMockClient(); - const remote = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const releaseLocal = manager.watch(TARGET, local.client); - const releaseRemote = manager.watch(OTHER_ENV_TARGET, remote.client); - - local.emit(BASE_STATUS); - remote.emit({ ...BASE_STATUS, refName: "remote-branch" }); - - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - expect(manager.getSnapshot(OTHER_ENV_TARGET).data?.refName).toBe("remote-branch"); - - releaseLocal(); - releaseRemote(); - }); - - it("returns null from refresh when no client is available", async () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - await expect(manager.refresh(TARGET)).resolves.toBeNull(); - }); - - it("returns empty state for null targets", () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - expect(manager.getSnapshot({ environmentId: null, cwd: null })).toEqual(EMPTY); - }); - }); - - describe("with subscribeClientChanges (reconnection)", () => { - it("waits for a delayed client registration", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => clients.get(envId)?.client ?? null, - getClientIdentity: (envId) => (clients.has(envId) ? envId : null), - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - - // Register the client - const mock = createMockClient(); - clients.set("env-local", mock); - for (const listener of connectionListeners) listener(); - - mock.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET)).toEqual({ - data: BASE_STATUS, - error: null, - cause: null, - isPending: false, - }); - - release(); - }); - - it("resubscribes after client is removed and re-registered", () => { - const connectionListeners = new Set<() => void>(); - const clients = new Map>(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => clients.get(envId)?.client ?? null, - getClientIdentity: (envId) => - clients.get(envId) ? `identity:${envId}:${clients.size}` : null, - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - // Register first client and watch - const first = createMockClient(); - clients.set("env-local", first); - const release = manager.watch(TARGET); - - first.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - - // Remove client - clients.delete("env-local"); - for (const listener of connectionListeners) listener(); - - expect(manager.getSnapshot(TARGET)).toEqual({ - data: BASE_STATUS, - error: null, - cause: null, - isPending: true, - }); - - // Register new client (different identity) - const second = createMockClient(); - clients.set("env-local", second); - for (const listener of connectionListeners) listener(); - - second.emit({ ...BASE_STATUS, refName: "reconnected-branch" }); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("reconnected-branch"); - - release(); - }); - - it("cleans up connection listener on unwatch", () => { - const connectionListeners = new Set<() => void>(); - const mock = createMockClient(); - - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - getClientIdentity: () => "id", - subscribeClientChanges: (listener) => { - connectionListeners.add(listener); - return () => connectionListeners.delete(listener); - }, - }); - - const release = manager.watch(TARGET); - expect(connectionListeners.size).toBe(1); - - release(); - expect(connectionListeners.size).toBe(0); - expect(mock.listeners.size).toBe(0); - }); - }); - - describe("with getClient config (one-shot)", () => { - it("resolves client from config and subscribes", () => { - const mock = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: (envId) => (envId === "env-local" ? mock.client : null), - }); - - const release = manager.watch(TARGET); - expect(mock.client.onStatus).toHaveBeenCalledOnce(); - - mock.emit(BASE_STATUS); - expect(manager.getSnapshot(TARGET).data?.refName).toBe("feature/push-status"); - - release(); - expect(mock.listeners.size).toBe(0); - }); - - it("returns noop when client is not available", () => { - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => null, - }); - - const release = manager.watch(TARGET); - expect(manager.getSnapshot(TARGET)).toEqual(PENDING); - release(); // should not throw - }); - }); - - describe("reset", () => { - it("tears down all active subscriptions", () => { - const mock = createMockClient(); - const manager = createVcsStatusManager({ - getRegistry: () => atomRegistry, - getClient: () => mock.client, - }); - - manager.watch(TARGET); - manager.watch(FRESH_TARGET); - expect(mock.listeners.size).toBe(2); - - manager.reset(); - expect(mock.listeners.size).toBe(0); - }); - }); -}); diff --git a/packages/client-runtime/src/vcsStatusState.ts b/packages/client-runtime/src/vcsStatusState.ts deleted file mode 100644 index 38379a7fe95..00000000000 --- a/packages/client-runtime/src/vcsStatusState.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { EnvironmentId, GitManagerServiceError, VcsStatusResult } from "@t3tools/contracts"; -import type * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; -import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; -import type { WsRpcClient } from "./wsRpcClient.ts"; - -/* ─── Types ─────────────────────────────────────────────────────────── */ - -export interface VcsStatusState { - readonly data: VcsStatusResult | null; - readonly error: GitManagerServiceError | null; - readonly cause: Cause.Cause | null; - readonly isPending: boolean; -} - -export interface VcsStatusTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; -} - -export type VcsStatusClient = Pick; - -interface WatchedEntry { - refCount: number; - teardown: () => void; -} - -/* ─── Constants ─────────────────────────────────────────────────────── */ - -const NOOP: () => void = () => undefined; - -export const EMPTY_VCS_STATUS_STATE = Object.freeze({ - data: null, - error: null, - cause: null, - isPending: false, -}); - -const INITIAL_VCS_STATUS_STATE = Object.freeze({ - data: null, - error: null, - cause: null, - isPending: true, -}); - -/* ─── Atoms ─────────────────────────────────────────────────────────── */ - -const knownVcsStatusKeys = new Set(); - -export const vcsStatusStateAtom = Atom.family((key: string) => { - knownVcsStatusKeys.add(key); - return Atom.make(INITIAL_VCS_STATUS_STATE).pipe( - Atom.keepAlive, - Atom.withLabel(`vcs-status:${key}`), - ); -}); - -export const EMPTY_VCS_STATUS_ATOM = Atom.make(EMPTY_VCS_STATUS_STATE).pipe( - Atom.keepAlive, - Atom.withLabel("vcs-status:null"), -); - -/* ─── Helpers ───────────────────────────────────────────────────────── */ - -export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null { - if (target.environmentId === null || target.cwd === null) { - return null; - } - return `${target.environmentId}:${target.cwd}`; -} - -/* ─── Subscription manager ──────────────────────────────────────────── */ - -export interface VcsStatusManagerConfig { - /** - * Get the atom registry to read/write VCS status atoms. - */ - readonly getRegistry: () => AtomRegistry.AtomRegistry; - /** Resolve a VCS client for an environment. */ - readonly getClient: (environmentId: EnvironmentId) => VcsStatusClient | null; - /** - * Optional: get a stable identity for the current client. - * Used to detect reconnections — when the identity changes the - * manager tears down the old `onStatus` stream and subscribes anew. - */ - readonly getClientIdentity?: (environmentId: EnvironmentId) => string | null; - /** - * Optional: subscribe to environment-connection changes. - * When provided the manager reacts to client appear / disappear / - * reconnect events instead of doing a one-shot resolution. - */ - readonly subscribeClientChanges?: (listener: () => void) => () => void; -} - -const VCS_STATUS_REFRESH_DEBOUNCE_MS = 1_000; -const nowMs = () => DateTime.toEpochMillis(DateTime.nowUnsafe()); - -export function createVcsStatusManager(config: VcsStatusManagerConfig) { - const watched = new Map(); - const refreshInFlight = new Map>(); - const lastRefreshAt = new Map(); - - /* ── Atom helpers ───────────────────────────────────────────────── */ - - function markPending(targetKey: string): void { - const atom = vcsStatusStateAtom(targetKey); - const current = config.getRegistry().get(atom); - const next: VcsStatusState = - current.data === null - ? INITIAL_VCS_STATUS_STATE - : { ...current, error: null, cause: null, isPending: true }; - if ( - current.data === next.data && - current.error === next.error && - current.cause === next.cause && - current.isPending === next.isPending - ) { - return; - } - config.getRegistry().set(atom, next); - } - - function setData(targetKey: string, status: VcsStatusResult): void { - config.getRegistry().set(vcsStatusStateAtom(targetKey), { - data: status, - error: null, - cause: null, - isPending: false, - }); - } - - /* ── Core subscription ──────────────────────────────────────────── */ - - function subscribeStream(targetKey: string, cwd: string, client: VcsStatusClient): () => void { - markPending(targetKey); - return client.onStatus({ cwd }, (status) => setData(targetKey, status), { - onResubscribe: () => markPending(targetKey), - }); - } - - /* ── Dynamic subscription (handles reconnection) ────────────────── */ - - function createDynamicSubscription(targetKey: string, target: VcsStatusTarget): () => void { - const environmentId = target.environmentId!; - const cwd = target.cwd!; - let currentIdentity: string | null = null; - let currentUnsub = NOOP; - - const sync = () => { - const client = config.getClient(environmentId); - const identity = client ? (config.getClientIdentity?.(environmentId) ?? environmentId) : null; - - if (!client || identity === null) { - if (currentIdentity !== null) { - currentUnsub(); - currentUnsub = NOOP; - currentIdentity = null; - } - markPending(targetKey); - return; - } - - if (currentIdentity === identity) return; - - currentUnsub(); - currentIdentity = identity; - currentUnsub = subscribeStream(targetKey, cwd, client); - }; - - const unsubChanges = config.subscribeClientChanges!(sync); - sync(); - - return () => { - unsubChanges(); - currentUnsub(); - }; - } - - /* ── Public API ─────────────────────────────────────────────────── */ - - /** - * Begin watching VCS status for `target`. - * - * Multiple watchers sharing the same `environmentId:cwd` key share - * one `onStatus` WS subscription (ref-counted). - * - * @param target The environment + cwd to watch. - * @param client Optional pre-resolved client — skips `getClient` - * lookup and reconnection handling. Useful in tests. - * @returns An unwatch function. - */ - function watch(target: VcsStatusTarget, client?: VcsStatusClient): () => void { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null || target.environmentId === null || target.cwd === null) { - return NOOP; - } - - const existing = watched.get(targetKey); - if (existing) { - existing.refCount += 1; - return () => unwatch(targetKey); - } - - let teardown: () => void; - - if (client) { - // Explicit client — direct subscription, no reconnection handling. - teardown = subscribeStream(targetKey, target.cwd, client); - } else if (config.subscribeClientChanges) { - // Dynamic client — subscribe to connection changes for reconnection. - teardown = createDynamicSubscription(targetKey, target); - } else { - // One-shot client resolution. - const resolved = config.getClient(target.environmentId); - if (!resolved) return NOOP; - teardown = subscribeStream(targetKey, target.cwd, resolved); - } - - watched.set(targetKey, { refCount: 1, teardown }); - return () => unwatch(targetKey); - } - - function unwatch(targetKey: string): void { - const entry = watched.get(targetKey); - if (!entry) return; - - entry.refCount -= 1; - if (entry.refCount > 0) return; - - entry.teardown(); - watched.delete(targetKey); - } - - /** - * Trigger a one-shot `refreshStatus` RPC for a target. - * Debounced (1 s) and deduplicated (in-flight). - * The server-side refresh pushes a new event on the existing - * `onStatus` stream, so the subscription picks it up automatically. - */ - function refresh( - target: VcsStatusTarget, - client?: VcsStatusClient, - ): Promise { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null || target.cwd === null) { - return Promise.resolve(null); - } - - const resolved = - client ?? (target.environmentId ? config.getClient(target.environmentId) : null); - if (!resolved) { - return Promise.resolve(getSnapshot(target).data); - } - - const existing = refreshInFlight.get(targetKey); - if (existing) return existing; - - const requestedAt = nowMs(); - const last = lastRefreshAt.get(targetKey) ?? 0; - if (requestedAt - last < VCS_STATUS_REFRESH_DEBOUNCE_MS) { - return Promise.resolve(getSnapshot(target).data); - } - - lastRefreshAt.set(targetKey, requestedAt); - const promise = resolved - .refreshStatus({ cwd: target.cwd }) - .finally(() => refreshInFlight.delete(targetKey)); - refreshInFlight.set(targetKey, promise); - return promise; - } - - function getSnapshot(target: VcsStatusTarget): VcsStatusState { - const targetKey = getVcsStatusTargetKey(target); - if (targetKey === null) return EMPTY_VCS_STATUS_STATE; - return config.getRegistry().get(vcsStatusStateAtom(targetKey)); - } - - function reset(): void { - for (const entry of watched.values()) { - entry.teardown(); - } - watched.clear(); - refreshInFlight.clear(); - lastRefreshAt.clear(); - for (const key of knownVcsStatusKeys) { - config.getRegistry().set(vcsStatusStateAtom(key), INITIAL_VCS_STATUS_STATE); - } - knownVcsStatusKeys.clear(); - } - - return { watch, refresh, getSnapshot, reset }; -} diff --git a/packages/client-runtime/src/wsRpcClient.test.ts b/packages/client-runtime/src/wsRpcClient.test.ts deleted file mode 100644 index 584fb958fba..00000000000 --- a/packages/client-runtime/src/wsRpcClient.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { - VcsStatusLocalResult, - VcsStatusRemoteResult, - VcsStatusStreamEvent, -} from "@t3tools/contracts"; -import { ORCHESTRATION_WS_METHODS, ThreadId, WS_METHODS } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; - -vi.mock("./wsTransport.ts", () => ({ - WsTransport: class WsTransport { - dispose = vi.fn(async () => undefined); - reconnect = vi.fn(async () => undefined); - request = vi.fn(); - requestStream = vi.fn(); - subscribe = vi.fn(() => () => undefined); - }, -})); - -import { createWsRpcClient } from "./wsRpcClient.ts"; -import type { WsTransport } from "./wsTransport.ts"; - -const baseLocalStatus: VcsStatusLocalResult = { - isRepo: true, - hasPrimaryRemote: true, - isDefaultRef: false, - refName: "feature/demo", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, -}; - -const baseRemoteStatus: VcsStatusRemoteResult = { - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -describe("createWsRpcClient", () => { - it("runs beforeReconnect before awaiting transport.reconnect", async () => { - const order: string[] = []; - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => { - order.push("reconnect"); - }), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe: vi.fn(() => () => undefined), - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport, { - beforeReconnect: () => { - order.push("beforeReconnect"); - }, - }); - - await client.reconnect(); - expect(order).toEqual(["beforeReconnect", "reconnect"]); - }); - - it("delegates heartbeat freshness to the transport", () => { - const isHeartbeatFresh = vi.fn(() => true); - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh, - request: vi.fn(), - requestStream: vi.fn(), - subscribe: vi.fn(() => () => undefined), - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - - expect(client.isHeartbeatFresh()).toBe(true); - expect(isHeartbeatFresh).toHaveBeenCalledOnce(); - }); - - it("reduces vcs status stream events into flat status snapshots", () => { - const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { - for (const event of [ - { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - }, - { - _tag: "remoteUpdated", - remote: baseRemoteStatus, - }, - { - _tag: "localUpdated", - local: { - ...baseLocalStatus, - hasWorkingTreeChanges: true, - }, - }, - ] satisfies VcsStatusStreamEvent[]) { - listener(event as TValue); - } - return () => undefined; - }); - - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe, - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - const listener = vi.fn(); - - client.vcs.onStatus({ cwd: "/repo" }, listener); - - expect(listener.mock.calls).toEqual([ - [ - { - ...baseLocalStatus, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - aheadOfDefaultCount: 0, - pr: null, - }, - ], - [ - { - ...baseLocalStatus, - ...baseRemoteStatus, - }, - ], - [ - { - ...baseLocalStatus, - ...baseRemoteStatus, - hasWorkingTreeChanges: true, - }, - ], - ]); - }); - - it("tags stream subscriptions for targeted resubscribe handling", () => { - const subscribe = vi.fn(() => () => undefined); - const transport = { - dispose: vi.fn(async () => undefined), - reconnect: vi.fn(async () => undefined), - isHeartbeatFresh: vi.fn(() => true), - request: vi.fn(), - requestStream: vi.fn(), - subscribe, - } satisfies Pick< - WsTransport, - "dispose" | "isHeartbeatFresh" | "reconnect" | "request" | "requestStream" | "subscribe" - >; - - const client = createWsRpcClient(transport as unknown as WsTransport); - const listener = vi.fn(); - - client.terminal.onMetadata(listener); - client.vcs.onStatus({ cwd: "/repo" }, listener); - client.server.subscribeConfig(listener); - client.orchestration.subscribeThread({ threadId: ThreadId.make("thread-1") }, listener); - - const subscribeCalls = subscribe.mock.calls as unknown as Array< - readonly [unknown, unknown, { readonly tag?: string }?] - >; - expect(subscribeCalls.map((call) => call[2]?.tag)).toEqual([ - WS_METHODS.subscribeTerminalMetadata, - WS_METHODS.subscribeVcsStatus, - WS_METHODS.subscribeServerConfig, - ORCHESTRATION_WS_METHODS.subscribeThread, - ]); - }); -}); diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts deleted file mode 100644 index c1c683616b2..00000000000 --- a/packages/client-runtime/src/wsRpcClient.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { - type GitActionProgressEvent, - type GitRunStackedActionInput, - type GitRunStackedActionResult, - type LocalApi, - ORCHESTRATION_WS_METHODS, - type RelayClientInstallProgressEvent, - type RelayClientStatus, - type ServerSettingsPatch, - type VcsStatusResult, - type VcsStatusStreamEvent, - WS_METHODS, -} from "@t3tools/contracts"; -import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; -import type * as Effect from "effect/Effect"; -import type * as Stream from "effect/Stream"; - -import { type WsRpcProtocolClient } from "./wsRpcProtocol.ts"; -import { WsTransport } from "./wsTransport.ts"; - -type RpcTag = keyof WsRpcProtocolClient & string; -type RpcMethod = WsRpcProtocolClient[TTag]; -type RpcInput = Parameters>[0]; - -interface StreamSubscriptionOptions { - readonly onResubscribe?: () => void; -} - -function subscriptionOptions( - options: StreamSubscriptionOptions | undefined, - tag: string, -): StreamSubscriptionOptions & { readonly tag: string } { - return { - ...options, - tag, - }; -} - -type RpcUnaryMethod = - RpcMethod extends (input: any, options?: any) => Effect.Effect - ? (input: RpcInput) => Promise - : never; - -type RpcUnaryNoArgMethod = - RpcMethod extends (input: any, options?: any) => Effect.Effect - ? () => Promise - : never; - -type RpcStreamMethod = - RpcMethod extends (input: any, options?: any) => Stream.Stream - ? (listener: (event: TEvent) => void, options?: StreamSubscriptionOptions) => () => void - : never; - -type RpcInputStreamMethod = - RpcMethod extends (input: any, options?: any) => Stream.Stream - ? ( - input: RpcInput, - listener: (event: TEvent) => void, - options?: StreamSubscriptionOptions, - ) => () => void - : never; - -interface GitRunStackedActionOptions { - readonly onProgress?: (event: GitActionProgressEvent) => void; -} - -export interface WsRpcClient { - readonly dispose: () => Promise; - readonly reconnect: () => Promise; - readonly isHeartbeatFresh: () => boolean; - readonly terminal: { - readonly open: RpcUnaryMethod; - readonly attach: RpcInputStreamMethod; - readonly write: RpcUnaryMethod; - readonly resize: RpcUnaryMethod; - readonly clear: RpcUnaryMethod; - readonly restart: RpcUnaryMethod; - readonly close: RpcUnaryMethod; - readonly onEvent: RpcStreamMethod; - readonly onMetadata: RpcStreamMethod; - }; - readonly projects: { - readonly searchEntries: RpcUnaryMethod; - readonly writeFile: RpcUnaryMethod; - }; - readonly filesystem: { - readonly browse: RpcUnaryMethod; - }; - readonly sourceControl: { - readonly lookupRepository: RpcUnaryMethod; - readonly cloneRepository: RpcUnaryMethod; - readonly publishRepository: RpcUnaryMethod; - }; - readonly shell: { - readonly openInEditor: (input: { - readonly cwd: Parameters[0]; - readonly editor: Parameters[1]; - }) => ReturnType; - }; - readonly vcs: { - readonly pull: RpcUnaryMethod; - readonly refreshStatus: RpcUnaryMethod; - readonly onStatus: ( - input: RpcInput, - listener: (status: VcsStatusResult) => void, - options?: StreamSubscriptionOptions, - ) => () => void; - readonly listRefs: RpcUnaryMethod; - readonly createWorktree: RpcUnaryMethod; - readonly removeWorktree: RpcUnaryMethod; - readonly createRef: RpcUnaryMethod; - readonly switchRef: RpcUnaryMethod; - readonly init: RpcUnaryMethod; - }; - readonly git: { - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Promise; - readonly resolvePullRequest: RpcUnaryMethod; - readonly preparePullRequestThread: RpcUnaryMethod< - typeof WS_METHODS.gitPreparePullRequestThread - >; - }; - readonly review: { - readonly getDiffPreview: RpcUnaryMethod; - }; - readonly server: { - readonly getConfig: RpcUnaryNoArgMethod; - readonly refreshProviders: ( - input?: RpcInput, - ) => ReturnType>; - readonly discoverSourceControl: RpcUnaryNoArgMethod< - typeof WS_METHODS.serverDiscoverSourceControl - >; - readonly updateProvider: RpcUnaryMethod; - readonly upsertKeybinding: RpcUnaryMethod; - readonly removeKeybinding: RpcUnaryMethod; - readonly getSettings: RpcUnaryNoArgMethod; - readonly updateSettings: ( - patch: ServerSettingsPatch, - ) => ReturnType>; - readonly subscribeConfig: RpcStreamMethod; - readonly subscribeLifecycle: RpcStreamMethod; - readonly subscribeAuthAccess: RpcStreamMethod; - readonly getTraceDiagnostics: RpcUnaryNoArgMethod; - readonly getProcessDiagnostics: RpcUnaryNoArgMethod< - typeof WS_METHODS.serverGetProcessDiagnostics - >; - readonly getProcessResourceHistory: RpcUnaryMethod< - typeof WS_METHODS.serverGetProcessResourceHistory - >; - readonly signalProcess: RpcUnaryMethod; - }; - readonly cloud: { - readonly getRelayClientStatus: RpcUnaryNoArgMethod; - readonly installRelayClient: ( - onProgress?: (event: RelayClientInstallProgressEvent) => void, - ) => Promise; - }; - readonly orchestration: { - readonly dispatchCommand: RpcUnaryMethod; - readonly getTurnDiff: RpcUnaryMethod; - readonly getFullThreadDiff: RpcUnaryMethod; - readonly getArchivedShellSnapshot: RpcUnaryNoArgMethod< - typeof ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot - >; - readonly subscribeShell: RpcStreamMethod; - readonly subscribeThread: RpcInputStreamMethod; - }; -} - -export interface CreateWsRpcClientOptions { - /** Runs immediately before `transport.reconnect()` (e.g. reset reconnect UI/backoff state). */ - readonly beforeReconnect?: () => void; -} - -export function createWsRpcClient( - transport: WsTransport, - options?: CreateWsRpcClientOptions, -): WsRpcClient { - return { - dispose: () => transport.dispose(), - isHeartbeatFresh: () => transport.isHeartbeatFresh(), - reconnect: async () => { - options?.beforeReconnect?.(); - await transport.reconnect(); - }, - terminal: { - open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), - attach: (input, listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.terminalAttach](input), - listener, - subscriptionOptions(options, WS_METHODS.terminalAttach), - ), - write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)), - resize: (input) => transport.request((client) => client[WS_METHODS.terminalResize](input)), - clear: (input) => transport.request((client) => client[WS_METHODS.terminalClear](input)), - restart: (input) => transport.request((client) => client[WS_METHODS.terminalRestart](input)), - close: (input) => transport.request((client) => client[WS_METHODS.terminalClose](input)), - onEvent: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeTerminalEvents]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeTerminalEvents), - ), - onMetadata: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeTerminalMetadata]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeTerminalMetadata), - ), - }, - projects: { - searchEntries: (input) => - transport.request((client) => client[WS_METHODS.projectsSearchEntries](input)), - writeFile: (input) => - transport.request((client) => client[WS_METHODS.projectsWriteFile](input)), - }, - filesystem: { - browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), - }, - sourceControl: { - lookupRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), - cloneRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), - publishRepository: (input) => - transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), - }, - shell: { - openInEditor: (input) => - transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), - }, - vcs: { - pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), - refreshStatus: (input) => - transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), - onStatus: (input, listener, options) => { - let current: VcsStatusResult | null = null; - return transport.subscribe( - (client) => client[WS_METHODS.subscribeVcsStatus](input), - (event: VcsStatusStreamEvent) => { - current = applyGitStatusStreamEvent(current, event); - listener(current); - }, - subscriptionOptions(options, WS_METHODS.subscribeVcsStatus), - ); - }, - listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), - createWorktree: (input) => - transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), - removeWorktree: (input) => - transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), - createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), - switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), - init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), - }, - git: { - runStackedAction: async (input, options) => { - let result: GitRunStackedActionResult | null = null; - - await transport.requestStream( - (client) => client[WS_METHODS.gitRunStackedAction](input), - (event) => { - options?.onProgress?.(event); - if (event.kind === "action_finished") { - result = event.result; - } - }, - ); - - if (result) { - return result; - } - - throw new Error("Git action stream completed without a final result."); - }, - resolvePullRequest: (input) => - transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), - preparePullRequestThread: (input) => - transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), - }, - review: { - getDiffPreview: (input) => - transport.request((client) => client[WS_METHODS.reviewGetDiffPreview](input)), - }, - server: { - getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), - refreshProviders: (input) => - transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), - discoverSourceControl: () => - transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), - updateProvider: (input) => - transport.request((client) => client[WS_METHODS.serverUpdateProvider](input)), - upsertKeybinding: (input) => - transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), - removeKeybinding: (input) => - transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), - getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), - updateSettings: (patch) => - transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), - subscribeConfig: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeServerConfig]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeServerConfig), - ), - subscribeLifecycle: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeServerLifecycle), - ), - subscribeAuthAccess: (listener, options) => - transport.subscribe( - (client) => client[WS_METHODS.subscribeAuthAccess]({}), - listener, - subscriptionOptions(options, WS_METHODS.subscribeAuthAccess), - ), - getTraceDiagnostics: () => - transport.request((client) => client[WS_METHODS.serverGetTraceDiagnostics]({})), - getProcessDiagnostics: () => - transport.request((client) => client[WS_METHODS.serverGetProcessDiagnostics]({})), - getProcessResourceHistory: (input) => - transport.request((client) => client[WS_METHODS.serverGetProcessResourceHistory](input)), - signalProcess: (input) => - transport.request((client) => client[WS_METHODS.serverSignalProcess](input)), - }, - cloud: { - getRelayClientStatus: () => - transport.request((client) => client[WS_METHODS.cloudGetRelayClientStatus]({})), - installRelayClient: async (onProgress) => { - let installed: RelayClientStatus | null = null; - await transport.requestStream( - (client) => client[WS_METHODS.cloudInstallRelayClient]({}), - (event) => { - onProgress?.(event); - if (event.type === "complete") { - installed = event.status; - } - }, - ); - if (installed) { - return installed; - } - throw new Error("Relay client install stream completed without a final status."); - }, - }, - orchestration: { - dispatchCommand: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.dispatchCommand](input)), - getTurnDiff: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.getTurnDiff](input)), - getFullThreadDiff: (input) => - transport.request((client) => client[ORCHESTRATION_WS_METHODS.getFullThreadDiff](input)), - getArchivedShellSnapshot: () => - transport.request((client) => - client[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot]({}), - ), - subscribeShell: (listener, options) => - transport.subscribe( - (client) => client[ORCHESTRATION_WS_METHODS.subscribeShell]({}), - listener, - subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeShell), - ), - subscribeThread: (input, listener, options) => - transport.subscribe( - (client) => client[ORCHESTRATION_WS_METHODS.subscribeThread](input), - listener, - subscriptionOptions(options, ORCHESTRATION_WS_METHODS.subscribeThread), - ), - }, - }; -} diff --git a/packages/client-runtime/src/wsRpcProtocol.ts b/packages/client-runtime/src/wsRpcProtocol.ts deleted file mode 100644 index 869c07f8766..00000000000 --- a/packages/client-runtime/src/wsRpcProtocol.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { WsRpcGroup } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schedule from "effect/Schedule"; -import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; -import * as Socket from "effect/unstable/socket/Socket"; - -import { - DEFAULT_RECONNECT_BACKOFF, - getReconnectDelayMs, - type ReconnectBackoffConfig, -} from "./reconnectBackoff.ts"; - -export interface WsProtocolLifecycleHandlers { - readonly getConnectionLabel?: () => string | null; - readonly getVersionMismatchHint?: () => string | null; - readonly isCloseIntentional?: () => boolean; - readonly isActive?: () => boolean; - readonly onAttempt?: (socketUrl: string) => void; - readonly onOpen?: () => void; - readonly onHeartbeatPing?: () => void; - readonly onHeartbeatPong?: () => void; - readonly onHeartbeatTimeout?: () => void; - readonly onRequestStart?: (info: { - readonly id: string; - readonly tag: string; - readonly stream: boolean; - }) => void; - readonly onRequestChunk?: (info: { - readonly id: string; - readonly tag: string; - readonly chunkCount: number; - }) => void; - readonly onRequestExit?: (info: { - readonly id: string; - readonly tag: string; - readonly stream: boolean; - }) => void; - readonly onRequestInterrupt?: (info: { readonly id: string; readonly tag?: string }) => void; - readonly onError?: (message: string) => void; - readonly onClose?: ( - details: { readonly code: number; readonly reason: string }, - context: { readonly intentional: boolean }, - ) => void; -} - -export interface WsRpcProtocolRequestTelemetry { - readonly onRequestSent?: (requestId: string, tag: string) => void; - readonly onRequestAcknowledged?: (requestId: string) => void; - readonly onClearTrackedRequests?: () => void; -} - -export interface WsRpcProtocolOptions { - /** Backoff configuration for reconnect retries. */ - readonly backoff?: ReconnectBackoffConfig; - /** - * Invoked before user {@link WsProtocolLifecycleHandlers} for each socket lifecycle event. - * Use for additive telemetry (connection state, clearing request trackers on disconnect). - */ - readonly telemetryLifecycle?: WsProtocolLifecycleHandlers; - /** Optional hooks around outbound requests and inbound RPC responses (latency tracking, etc.). */ - readonly requestTelemetry?: WsRpcProtocolRequestTelemetry; -} - -export const makeWsRpcProtocolClient = RpcClient.make(WsRpcGroup); -type RpcClientFactory = typeof makeWsRpcProtocolClient; -export type WsRpcProtocolClient = - RpcClientFactory extends Effect.Effect ? Client : never; -export type WsRpcProtocolSocketUrlProvider = string | (() => Promise); - -function formatSocketErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); -} - -function resolveWsRpcSocketUrl(rawUrl: string): string { - const resolved = new URL(rawUrl); - if (resolved.protocol !== "ws:" && resolved.protocol !== "wss:") { - throw new Error(`Unsupported websocket transport URL protocol: ${resolved.protocol}`); - } - - resolved.pathname = "/ws"; - return resolved.toString(); -} - -type ResolvedLifecycleHandlers = Required< - Pick< - WsProtocolLifecycleHandlers, - | "getConnectionLabel" - | "getVersionMismatchHint" - | "isCloseIntentional" - | "isActive" - | "onAttempt" - | "onOpen" - | "onHeartbeatPing" - | "onHeartbeatPong" - | "onHeartbeatTimeout" - | "onError" - | "onClose" - > ->; - -function defaultLifecycleHandlers(): ResolvedLifecycleHandlers { - return { - onAttempt: () => undefined, - onOpen: () => undefined, - onHeartbeatPing: () => undefined, - onHeartbeatPong: () => undefined, - onHeartbeatTimeout: () => undefined, - onError: () => undefined, - onClose: () => undefined, - getConnectionLabel: () => null, - getVersionMismatchHint: () => null, - isCloseIntentional: () => false, - isActive: () => true, - }; -} - -function resolveLifecycleHandlers( - handlers: WsProtocolLifecycleHandlers | undefined, - telemetryLifecycle: WsProtocolLifecycleHandlers | undefined, -): ResolvedLifecycleHandlers { - const defaults = defaultLifecycleHandlers(); - const isActive = handlers?.isActive ?? telemetryLifecycle?.isActive ?? defaults.isActive; - const isCloseIntentional = - handlers?.isCloseIntentional ?? - telemetryLifecycle?.isCloseIntentional ?? - defaults.isCloseIntentional; - - return { - getConnectionLabel: () => - handlers?.getConnectionLabel?.() ?? telemetryLifecycle?.getConnectionLabel?.() ?? null, - getVersionMismatchHint: () => - handlers?.getVersionMismatchHint?.() ?? - telemetryLifecycle?.getVersionMismatchHint?.() ?? - null, - isActive, - isCloseIntentional, - onAttempt: (socketUrl) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onAttempt?.(socketUrl); - handlers?.onAttempt?.(socketUrl); - }, - onOpen: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onOpen?.(); - handlers?.onOpen?.(); - }, - onHeartbeatPing: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatPing?.(); - handlers?.onHeartbeatPing?.(); - }, - onHeartbeatPong: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatPong?.(); - handlers?.onHeartbeatPong?.(); - }, - onHeartbeatTimeout: () => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onHeartbeatTimeout?.(); - handlers?.onHeartbeatTimeout?.(); - }, - onError: (message) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onError?.(message); - handlers?.onError?.(message); - }, - onClose: (details, context) => { - if (!isActive()) { - return; - } - telemetryLifecycle?.onClose?.(details, context); - handlers?.onClose?.(details, context); - }, - }; -} - -export function createWsRpcProtocolLayer( - url: WsRpcProtocolSocketUrlProvider, - handlers?: WsProtocolLifecycleHandlers, - options?: WsRpcProtocolOptions, -) { - const lifecycle = resolveLifecycleHandlers(handlers, options?.telemetryLifecycle); - const backoff = options?.backoff ?? DEFAULT_RECONNECT_BACKOFF; - const requestTelemetry = options?.requestTelemetry; - const resolvedUrl = - typeof url === "function" - ? Effect.promise(() => url()).pipe( - Effect.map((rawUrl) => resolveWsRpcSocketUrl(rawUrl)), - Effect.tapError((error) => - Effect.sync(() => { - lifecycle.onError(formatSocketErrorMessage(error)); - }), - ), - Effect.orDie, - ) - : resolveWsRpcSocketUrl(url); - - const trackingWebSocketConstructorLayer = Layer.succeed( - Socket.WebSocketConstructor, - (socketUrl, protocols) => { - lifecycle.onAttempt(socketUrl); - const socket = new globalThis.WebSocket(socketUrl, protocols); - - socket.addEventListener( - "open", - () => { - lifecycle.onOpen(); - }, - { once: true }, - ); - socket.addEventListener( - "error", - () => { - lifecycle.onError("Unable to connect to the T3 server WebSocket."); - }, - { once: true }, - ); - socket.addEventListener("message", (event) => { - try { - const message = JSON.parse(String(event.data)) as { readonly _tag?: string }; - if (message._tag === "Pong") { - lifecycle.onHeartbeatPong(); - } - } catch { - // Ignore malformed messages here; the Effect RPC parser still owns protocol errors. - } - }); - socket.addEventListener( - "close", - (event) => { - lifecycle.onClose( - { - code: event.code, - reason: event.reason, - }, - { - intentional: lifecycle.isCloseIntentional(), - }, - ); - }, - { once: true }, - ); - - return socket; - }, - ); - const socketLayer = Socket.layerWebSocket(resolvedUrl).pipe( - Layer.provide(trackingWebSocketConstructorLayer), - ); - - const baseSchedule = - backoff.maxRetries === null ? Schedule.forever : Schedule.recurs(backoff.maxRetries); - const retryPolicy = Schedule.addDelay(baseSchedule, (retryCount) => - Effect.succeed(Duration.millis(getReconnectDelayMs(retryCount, backoff) ?? 0)), - ); - const protocolLayer = Layer.effect( - RpcClient.Protocol, - Effect.map( - RpcClient.makeProtocolSocket({ - retryPolicy, - retryTransientErrors: true, - }), - (protocol) => ({ - ...protocol, - run: (clientId, writeResponse) => - protocol.run(clientId, (response) => { - if (response._tag === "Chunk" || response._tag === "Exit") { - requestTelemetry?.onRequestAcknowledged?.(response.requestId); - } else if (response._tag === "ClientProtocolError" || response._tag === "Defect") { - requestTelemetry?.onClearTrackedRequests?.(); - } - return writeResponse(response); - }), - send: (clientId, request, transferables) => { - if (request._tag === "Request") { - requestTelemetry?.onRequestSent?.(request.id, request.tag); - if (lifecycle.isActive()) { - handlers?.onRequestStart?.({ - id: request.id, - tag: request.tag, - stream: false, - }); - } - } - return protocol.send(clientId, request, transferables); - }, - }), - ), - ); - const connectionHooksLayer = Layer.succeed( - RpcClient.ConnectionHooks, - RpcClient.ConnectionHooks.of({ - onConnect: Effect.void, - onDisconnect: Effect.void, - }), - ); - - return protocolLayer.pipe( - Layer.provide(Layer.mergeAll(socketLayer, RpcSerialization.layerJson, connectionHooksLayer)), - ); -} diff --git a/packages/client-runtime/src/wsTransport.test.ts b/packages/client-runtime/src/wsTransport.test.ts deleted file mode 100644 index 72a698d2fcf..00000000000 --- a/packages/client-runtime/src/wsTransport.test.ts +++ /dev/null @@ -1,959 +0,0 @@ -import { WS_METHODS } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Stream from "effect/Stream"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { WsTransport } from "./wsTransport.ts"; - -type WsEventType = "open" | "message" | "close" | "error"; -type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; -type WsListener = (event?: WsEvent) => void; - -const sockets: MockWebSocket[] = []; - -class MockWebSocket { - static readonly CONNECTING = 0; - static readonly OPEN = 1; - static readonly CLOSING = 2; - static readonly CLOSED = 3; - - readyState = MockWebSocket.CONNECTING; - readonly sent: string[] = []; - readonly url: string; - private readonly listeners = new Map>(); - - constructor(url: string) { - this.url = url; - sockets.push(this); - } - - addEventListener(type: WsEventType, listener: WsListener) { - const listeners = this.listeners.get(type) ?? new Set(); - listeners.add(listener); - this.listeners.set(type, listeners); - } - - removeEventListener(type: WsEventType, listener: WsListener) { - this.listeners.get(type)?.delete(listener); - } - - send(data: string) { - this.sent.push(data); - } - - close(code = 1000, reason = "") { - this.readyState = MockWebSocket.CLOSED; - this.emit("close", { code, reason, type: "close" }); - } - - open() { - this.readyState = MockWebSocket.OPEN; - this.emit("open", { type: "open" }); - } - - serverMessage(data: unknown) { - this.emit("message", { data, type: "message" }); - } - - error() { - this.emit("error", { type: "error" }); - } - - private emit(type: WsEventType, event?: WsEvent) { - const listeners = this.listeners.get(type); - if (!listeners) return; - for (const listener of listeners) { - listener(event); - } - } -} - -const originalWebSocket = globalThis.WebSocket; -const originalFetch = globalThis.fetch; -const transports: WsTransport[] = []; - -function getSocket(): MockWebSocket { - const socket = sockets.at(-1); - if (!socket) { - throw new Error("Expected a websocket instance"); - } - return socket; -} - -async function waitFor(assertion: () => void, timeoutMs = 1_000): Promise { - const startedAt = performance.now(); - for (;;) { - try { - assertion(); - return; - } catch (error) { - if (performance.now() - startedAt >= timeoutMs) { - throw error; - } - await Effect.runPromise(Effect.sleep(Duration.millis(10))); - } - } -} - -function createTransport(...args: ConstructorParameters): WsTransport { - const transport = new WsTransport(...args); - transports.push(transport); - return transport; -} - -beforeEach(() => { - vi.useRealTimers(); - sockets.length = 0; - transports.length = 0; - - Object.defineProperty(globalThis, "window", { - configurable: true, - value: { - location: { - origin: "http://localhost:3020", - hostname: "localhost", - port: "3020", - protocol: "http:", - }, - desktopBridge: undefined, - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { onLine: true }, - }); - - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; -}); - -afterEach(async () => { - await Promise.allSettled(transports.map((transport) => transport.dispose())); - transports.length = 0; - globalThis.WebSocket = originalWebSocket; - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); -}); - -describe("WsTransport", () => { - it("normalizes root websocket urls to /ws and preserves query params", async () => { - const transport = createTransport("ws://localhost:3020/?token=secret-token"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("ws://localhost:3020/ws?token=secret-token"); - await transport.dispose(); - }); - - it("uses an explicit secure websocket base url", async () => { - const transport = createTransport("wss://app.example.com"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("wss://app.example.com/ws"); - await transport.dispose(); - }); - - it("uses an explicit insecure websocket base url for remote backends", async () => { - const transport = createTransport("ws://192.168.1.44:3773"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("ws://192.168.1.44:3773/ws"); - await transport.dispose(); - }); - - it("supports async websocket url providers", async () => { - const transport = createTransport(async () => "wss://remote.example.com/?wsTicket=dynamic"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(getSocket().url).toBe("wss://remote.example.com/ws?wsTicket=dynamic"); - await transport.dispose(); - }); - - it("invokes optional lifecycle handlers when the socket opens and closes", async () => { - const onOpen = vi.fn(); - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { - onOpen, - onClose, - }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(onOpen).toHaveBeenCalledOnce(); - }); - - socket.close(1012, "service restart"); - - await waitFor(() => { - expect(onClose).toHaveBeenCalledWith( - { - code: 1012, - reason: "service restart", - }, - { - intentional: false, - }, - ); - }); - - await transport.dispose(); - }); - - it("tracks heartbeat freshness from websocket pongs", async () => { - const nowSpy = vi.spyOn(performance, "now").mockReturnValue(1_000); - const onHeartbeatPong = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - expect(transport.isHeartbeatFresh()).toBe(false); - - const socket = getSocket(); - socket.open(); - socket.serverMessage(JSON.stringify({ _tag: "Pong" })); - - await waitFor(() => { - expect(onHeartbeatPong).toHaveBeenCalledOnce(); - }); - - expect(transport.isHeartbeatFresh()).toBe(true); - expect(transport.isHeartbeatFresh(500)).toBe(true); - - nowSpy.mockReturnValue(1_501); - expect(transport.isHeartbeatFresh(500)).toBe(false); - - await transport.dispose(); - }); - - it("clears heartbeat freshness when reconnecting", async () => { - vi.spyOn(performance, "now").mockReturnValue(1_000); - const onHeartbeatPong = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onHeartbeatPong }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - firstSocket.serverMessage(JSON.stringify({ _tag: "Pong" })); - - await waitFor(() => { - expect(onHeartbeatPong).toHaveBeenCalledOnce(); - }); - expect(transport.isHeartbeatFresh()).toBe(true); - - await transport.reconnect(); - - expect(transport.isHeartbeatFresh()).toBe(false); - - await transport.dispose(); - }); - - it("does not report an intentional dispose as a close", async () => { - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onClose }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - await transport.dispose(); - - expect(onClose).not.toHaveBeenCalled(); - }); - - it("ignores stale socket lifecycle events after reconnect starts a new session", async () => { - const onClose = vi.fn(); - const transport = createTransport("ws://localhost:3020", { onClose }); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - firstSocket.close(1006, "stale close"); - - expect(onClose).not.toHaveBeenCalled(); - - await transport.dispose(); - }); - - it("reconnects the websocket session without disposing the transport", async () => { - const transport = createTransport("ws://localhost:3020"); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - expect(secondSocket).not.toBe(firstSocket); - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - secondSocket.open(); - - await waitFor(() => { - expect(secondSocket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(secondSocket.sent[0] ?? "{}") as { id: string }; - secondSocket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("sends unary RPC requests and resolves successful exits", async () => { - const transport = createTransport("ws://localhost:3020"); - - const requestPromise = transport.request((client) => - client[WS_METHODS.serverUpsertKeybinding]({ - command: "terminal.toggle", - key: "ctrl+k", - }), - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { - _tag: string; - id: string; - payload: unknown; - tag: string; - }; - expect(requestMessage).toMatchObject({ - _tag: "Request", - tag: WS_METHODS.serverUpsertKeybinding, - payload: { - command: "terminal.toggle", - key: "ctrl+k", - }, - }); - - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: { - keybindings: [], - issues: [], - }, - }, - }), - ); - - await expect(requestPromise).resolves.toEqual({ - keybindings: [], - issues: [], - }); - - await transport.dispose(); - }); - - it("delivers stream chunks to subscribers", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; - expect(requestMessage.tag).toBe(WS_METHODS.subscribeServerLifecycle); - - const welcomeEvent = { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/workspace", - projectName: "workspace", - }, - }; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [welcomeEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenCalledWith(welcomeEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("re-subscribes stream listeners after the stream exits", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: firstRequest.id, - values: [ - { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/one", - projectName: "one", - }, - }, - ], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).toHaveBeenCalledOnce(); - - const secondRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string; tag?: string }) - .find( - (message): message is { _tag: "Request"; id: string; tag: string } => - message._tag === "Request" && message.id !== firstRequest.id, - ); - if (!secondRequest) { - throw new Error("Expected a resubscribe request"); - } - expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); - expect(secondRequest.id).not.toBe(firstRequest.id); - - const secondEvent = { - version: 1, - sequence: 2, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/two", - projectName: "two", - }, - }; - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: secondRequest.id, - values: [secondEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(secondEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("re-subscribes live stream listeners after an explicit transport reconnect", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const firstSocket = getSocket(); - firstSocket.open(); - - await waitFor(() => { - expect(firstSocket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(firstSocket.sent[0] ?? "{}") as { id: string }; - const firstEvent = { - version: 1, - sequence: 1, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/one", - projectName: "one", - }, - }; - - firstSocket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: firstRequest.id, - values: [firstEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(firstEvent); - }); - - await transport.reconnect(); - - await waitFor(() => { - expect(sockets).toHaveLength(2); - }); - - const secondSocket = getSocket(); - expect(secondSocket).not.toBe(firstSocket); - expect(firstSocket.readyState).toBe(MockWebSocket.CLOSED); - - secondSocket.open(); - - await waitFor(() => { - expect(secondSocket.sent).toHaveLength(1); - }); - - const secondRequest = JSON.parse(secondSocket.sent[0] ?? "{}") as { - id: string; - tag: string; - }; - expect(secondRequest.tag).toBe(WS_METHODS.subscribeServerLifecycle); - expect(secondRequest.id).not.toBe(firstRequest.id); - expect(onResubscribe).toHaveBeenCalledOnce(); - - const secondEvent = { - version: 1, - sequence: 2, - type: "welcome", - payload: { - environment: { - environmentId: "environment-local", - label: "Local environment", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - cwd: "/tmp/two", - projectName: "two", - }, - }; - - secondSocket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: secondRequest.id, - values: [secondEvent], - }), - ); - - await waitFor(() => { - expect(listener).toHaveBeenLastCalledWith(secondEvent); - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("does not fire onResubscribe when the first stream attempt exits before any value", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - const onResubscribe = vi.fn(); - - const unsubscribe = transport.subscribe( - (client) => client[WS_METHODS.subscribeServerLifecycle]({}), - listener, - { onResubscribe }, - ); - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - const socket = getSocket(); - socket.open(); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const firstRequest = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: firstRequest.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await waitFor(() => { - const nextRequest = socket.sent - .map((message) => JSON.parse(message) as { _tag?: string; id?: string }) - .find((message) => message._tag === "Request" && message.id !== firstRequest.id); - expect(nextRequest).toBeDefined(); - }); - expect(onResubscribe).not.toHaveBeenCalled(); - expect(listener).not.toHaveBeenCalled(); - - unsubscribe(); - await transport.dispose(); - }); - - it("does not retry stream subscriptions after application-level failures", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - let attempts = 0; - - const unsubscribe = transport.subscribe( - () => - Stream.suspend(() => { - attempts += 1; - return Stream.fail(new Error("Git command failed in GitCore.statusDetails")); - }), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(attempts).toBe(1); - }); - await Effect.runPromise(Effect.sleep(Duration.millis(50))); - - expect(attempts).toBe(1); - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription failed", { - error: "Git command failed in GitCore.statusDetails", - }); - expect(warnSpy).not.toHaveBeenCalledWith( - "WebSocket RPC subscription disconnected", - expect.anything(), - ); - - unsubscribe(); - await transport.dispose(); - }); - - it("keeps retrying stream subscriptions after transport failures", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - let attempts = 0; - - const unsubscribe = transport.subscribe( - () => - Stream.suspend(() => { - attempts += 1; - return Stream.fail(new Error("Socket is not connected")); - }), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(attempts).toBeGreaterThanOrEqual(2); - }); - - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { - error: "Socket is not connected", - }); - - unsubscribe(); - await transport.dispose(); - }); - - it("logs a transport disconnect once even when multiple subscriptions fail together", async () => { - const warnSpy = vi.fn(); - const transport = createTransport("ws://localhost:3020", undefined, { logWarning: warnSpy }); - - const unsubscribeA = transport.subscribe( - () => Stream.fail(new Error("SocketCloseError: 1006")), - vi.fn(), - { retryDelay: 10 }, - ); - const unsubscribeB = transport.subscribe( - () => Stream.fail(new Error("SocketCloseError: 1006")), - vi.fn(), - { retryDelay: 10 }, - ); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - - getSocket().open(); - - await waitFor(() => { - expect(warnSpy).toHaveBeenCalledTimes(1); - }); - expect(warnSpy).toHaveBeenCalledWith("WebSocket RPC subscription disconnected", { - error: "SocketCloseError: 1006", - }); - - unsubscribeA(); - unsubscribeB(); - await transport.dispose(); - }); - - it("streams finite request events without re-subscribing", async () => { - const transport = createTransport("ws://localhost:3020"); - const listener = vi.fn(); - - await waitFor(() => { - expect(sockets).toHaveLength(1); - }); - const socket = getSocket(); - socket.open(); - - const requestPromise = transport.requestStream( - (client) => - client[WS_METHODS.gitRunStackedAction]({ - actionId: "action-1", - cwd: "/repo", - action: "commit", - }), - listener, - ); - - await waitFor(() => { - expect(socket.sent).toHaveLength(1); - }); - - const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string }; - const progressEvent = { - actionId: "action-1", - cwd: "/repo", - action: "commit", - kind: "phase_started", - phase: "commit", - label: "Committing...", - } as const; - - socket.serverMessage( - JSON.stringify({ - _tag: "Chunk", - requestId: requestMessage.id, - values: [progressEvent], - }), - ); - socket.serverMessage( - JSON.stringify({ - _tag: "Exit", - requestId: requestMessage.id, - exit: { - _tag: "Success", - value: null, - }, - }), - ); - - await expect(requestPromise).resolves.toBeUndefined(); - expect(listener).toHaveBeenCalledWith(progressEvent); - expect( - socket.sent.filter((message) => { - const parsed = JSON.parse(message) as { _tag?: string; tag?: string }; - return parsed._tag === "Request" && parsed.tag === WS_METHODS.gitRunStackedAction; - }), - ).toHaveLength(1); - await transport.dispose(); - }); - - it("closes the client scope on the transport runtime before disposing the runtime", async () => { - const callOrder: string[] = []; - let resolveClose!: () => void; - const closePromise = new Promise((resolve) => { - resolveClose = resolve; - }); - - const runtime = { - runPromise: vi.fn(async () => { - callOrder.push("close:start"); - await closePromise; - callOrder.push("close:done"); - return undefined; - }), - dispose: vi.fn(async () => { - callOrder.push("runtime:dispose"); - }), - }; - const transport = { - disposed: false, - session: { - clientScope: {} as never, - runtime, - }, - closeSession: ( - WsTransport.prototype as unknown as { - closeSession: (session: { - clientScope: unknown; - runtime: { dispose: () => Promise; runPromise: () => Promise }; - }) => Promise; - } - ).closeSession, - } as unknown as WsTransport; - - void WsTransport.prototype.dispose.call(transport); - - expect(runtime.runPromise).toHaveBeenCalledTimes(1); - expect(runtime.dispose).not.toHaveBeenCalled(); - expect((transport as unknown as { disposed: boolean }).disposed).toBe(true); - - resolveClose(); - - await waitFor(() => { - expect(runtime.dispose).toHaveBeenCalledTimes(1); - }); - - expect(callOrder).toEqual(["close:start", "close:done", "runtime:dispose"]); - }); -}); diff --git a/packages/client-runtime/src/wsTransport.ts b/packages/client-runtime/src/wsTransport.ts deleted file mode 100644 index a68b0aba469..00000000000 --- a/packages/client-runtime/src/wsTransport.ts +++ /dev/null @@ -1,377 +0,0 @@ -import * as Cause from "effect/Cause"; -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 ManagedRuntime from "effect/ManagedRuntime"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import { RpcClient } from "effect/unstable/rpc"; - -import { isTransportConnectionErrorMessage } from "./transportError.ts"; -import { - createWsRpcProtocolLayer, - makeWsRpcProtocolClient, - type WsProtocolLifecycleHandlers, - type WsRpcProtocolClient, - type WsRpcProtocolSocketUrlProvider, -} from "./wsRpcProtocol.ts"; - -export interface WsTransportOptions { - /** - * Merged into the transport `ManagedRuntime` alongside the RPC protocol layer - * (for example a `Tracer` layer for OTLP). - */ - readonly tracingLayer?: Layer.Layer; - /** - * Override protocol construction (defaults to {@link createWsRpcProtocolLayer}). - * The web app supplies its instrumented layer factory. - */ - readonly createProtocolLayer?: ( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - ) => Layer.Layer; - readonly logWarning?: (message: string, metadata: { readonly error: string }) => void; - /** - * Invoked at the start of {@link WsTransport.reconnect} before the session is replaced. - */ - readonly onBeforeReconnect?: () => void; -} - -interface SubscribeOptions { - readonly retryDelay?: Duration.Input; - readonly onResubscribe?: () => void; - readonly tag?: string; -} - -const DEFAULT_SUBSCRIPTION_RETRY_DELAY = Duration.millis(250); -const NOOP: () => void = () => undefined; - -interface TransportSession { - readonly clientPromise: Promise; - readonly clientScope: Scope.Closeable; - readonly runtime: ManagedRuntime.ManagedRuntime; -} - -function formatErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - - return String(error); -} - -export class WsTransport { - private readonly url: WsRpcProtocolSocketUrlProvider; - private readonly lifecycleHandlers: WsProtocolLifecycleHandlers | undefined; - private readonly options: WsTransportOptions | undefined; - private disposed = false; - private hasReportedTransportDisconnect = false; - private intentionalCloseDepth = 0; - private nextSessionId = 0; - private activeSessionId = 0; - private lastHeartbeatPongAt: number | null = null; - private readonly streamRequestStartListeners = new Set< - (info: { readonly tag: string }) => void - >(); - private reconnectChain: Promise = Promise.resolve(); - private session: TransportSession; - - constructor( - url: WsRpcProtocolSocketUrlProvider, - lifecycleHandlers?: WsProtocolLifecycleHandlers, - options?: WsTransportOptions, - ) { - this.url = url; - this.lifecycleHandlers = lifecycleHandlers; - this.options = options; - this.session = this.createSession(); - } - - async request( - execute: (client: WsRpcProtocolClient) => Effect.Effect, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const session = this.session; - const client = await session.clientPromise; - return await session.runtime.runPromise(Effect.suspend(() => execute(client))); - } - - async requestStream( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - ): Promise { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const session = this.session; - const client = await session.clientPromise; - await session.runtime.runPromise( - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - try { - listener(value); - } catch { - // Ignore listener errors so the stream can finish cleanly. - } - }), - ), - ); - } - - subscribe( - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - options?: SubscribeOptions, - ): () => void { - if (this.disposed) { - return NOOP; - } - - let active = true; - let hasReceivedValue = false; - const retryDelayMs = Duration.toMillis( - Duration.fromInputUnsafe(options?.retryDelay ?? DEFAULT_SUBSCRIPTION_RETRY_DELAY), - ); - let cancelCurrentStream: () => void = NOOP; - const onStreamRequestStart = (info: { readonly tag: string }) => { - if ( - !hasReceivedValue || - !active || - (options?.tag !== undefined && info.tag !== options.tag) - ) { - return; - } - - try { - options?.onResubscribe?.(); - } catch { - // Ignore reconnect hook failures so the stream can recover. - } - }; - this.streamRequestStartListeners.add(onStreamRequestStart); - - void (async () => { - for (;;) { - if (!active || this.disposed) { - return; - } - - const session = this.session; - try { - if (hasReceivedValue) { - try { - options?.onResubscribe?.(); - } catch { - // Ignore reconnect hook failures so the stream can recover. - } - } - const runningStream = this.runStreamOnSession( - session, - connect, - listener, - () => active, - () => { - this.hasReportedTransportDisconnect = false; - hasReceivedValue = true; - }, - ); - cancelCurrentStream = runningStream.cancel; - await runningStream.completed; - cancelCurrentStream = NOOP; - } catch (error) { - cancelCurrentStream = NOOP; - if (!active || this.disposed) { - return; - } - - // Skip retry if the session has already been replaced by a reconnect. - if (session !== this.session) { - continue; - } - - const formattedError = formatErrorMessage(error); - if (!isTransportConnectionErrorMessage(formattedError)) { - this.logWarning("WebSocket RPC subscription failed", { error: formattedError }); - return; - } - - if (!this.hasReportedTransportDisconnect) { - this.logWarning("WebSocket RPC subscription disconnected", { - error: formattedError, - }); - } - this.hasReportedTransportDisconnect = true; - await sleep(retryDelayMs); - } - } - })(); - - return () => { - active = false; - this.streamRequestStartListeners.delete(onStreamRequestStart); - cancelCurrentStream(); - }; - } - - async reconnect() { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - const reconnectOperation = this.reconnectChain.then(async () => { - if (this.disposed) { - throw new Error("Transport disposed"); - } - - try { - this.options?.onBeforeReconnect?.(); - } catch { - // Ignore hook failures so reconnect can proceed. - } - - this.lastHeartbeatPongAt = null; - const previousSession = this.session; - this.session = this.createSession(); - await this.closeSession(previousSession); - }); - - this.reconnectChain = reconnectOperation.catch(() => undefined); - await reconnectOperation; - } - - isHeartbeatFresh(maxAgeMs = 15_000): boolean { - return ( - this.lastHeartbeatPongAt !== null && performance.now() - this.lastHeartbeatPongAt <= maxAgeMs - ); - } - - async dispose() { - if (this.disposed) { - return; - } - - this.disposed = true; - await this.closeSession(this.session); - } - - private closeSession(session: TransportSession) { - this.intentionalCloseDepth += 1; - return session.runtime.runPromise(Scope.close(session.clientScope, Exit.void)).finally(() => { - this.intentionalCloseDepth = Math.max(0, this.intentionalCloseDepth - 1); - session.runtime.dispose(); - }); - } - - private createSession(): TransportSession { - const protocolFactory = this.options?.createProtocolLayer ?? createWsRpcProtocolLayer; - const sessionId = this.nextSessionId + 1; - this.nextSessionId = sessionId; - this.activeSessionId = sessionId; - const lifecycleHandlers = this.lifecycleHandlers; - const protocolLayer = protocolFactory(this.url, { - ...lifecycleHandlers, - isActive: () => - !this.disposed && - this.activeSessionId === sessionId && - (lifecycleHandlers?.isActive?.() ?? true), - isCloseIntentional: () => - this.disposed || - this.intentionalCloseDepth > 0 || - lifecycleHandlers?.isCloseIntentional?.() === true, - onHeartbeatPong: () => { - this.lastHeartbeatPongAt = performance.now(); - lifecycleHandlers?.onHeartbeatPong?.(); - }, - onRequestStart: (info) => { - lifecycleHandlers?.onRequestStart?.(info); - if (!info.stream) { - return; - } - for (const listener of this.streamRequestStartListeners) { - listener({ tag: info.tag }); - } - }, - }); - const rootLayer = this.options?.tracingLayer - ? Layer.mergeAll(protocolLayer, this.options.tracingLayer) - : protocolLayer; - const runtime = ManagedRuntime.make(rootLayer); - const clientScope = runtime.runSync(Scope.make()); - return { - runtime, - clientScope, - clientPromise: runtime.runPromise(Scope.provide(clientScope)(makeWsRpcProtocolClient)), - }; - } - - private logWarning(message: string, metadata: { readonly error: string }) { - const logWarning = this.options?.logWarning; - if (logWarning) { - logWarning(message, metadata); - } else { - Effect.runSync(Effect.logWarning(message, metadata)); - } - } - - private runStreamOnSession( - session: TransportSession, - connect: (client: WsRpcProtocolClient) => Stream.Stream, - listener: (value: TValue) => void, - isActive: () => boolean, - markValueReceived: () => void, - ): { - readonly cancel: () => void; - readonly completed: Promise; - } { - let resolveCompleted!: () => void; - let rejectCompleted!: (error: unknown) => void; - const completed = new Promise((resolve, reject) => { - resolveCompleted = resolve; - rejectCompleted = reject; - }); - const cancel = session.runtime.runCallback( - Effect.promise(() => session.clientPromise).pipe( - Effect.flatMap((client) => - Stream.runForEach(connect(client), (value) => - Effect.sync(() => { - if (!isActive()) { - return; - } - - markValueReceived(); - try { - listener(value); - } catch { - // Ignore listener errors so the stream stays live. - } - }), - ), - ), - ), - { - onExit: (exit) => { - if (Exit.isSuccess(exit)) { - resolveCompleted(); - return; - } - - rejectCompleted(Cause.squash(exit.cause)); - }, - }, - ); - - return { - cancel, - completed, - }; - } -} - -function sleep(ms: number): Promise { - return Effect.runPromise(Effect.sleep(Duration.millis(ms))); -} diff --git a/packages/client-runtime/structure.png b/packages/client-runtime/structure.png new file mode 100644 index 00000000000..4224421e560 Binary files /dev/null and b/packages/client-runtime/structure.png differ diff --git a/packages/client-runtime/tsconfig.json b/packages/client-runtime/tsconfig.json index 73a306f847a..564a5990051 100644 --- a/packages/client-runtime/tsconfig.json +++ b/packages/client-runtime/tsconfig.json @@ -1,5 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": {}, "include": ["src"] } diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts new file mode 100644 index 00000000000..bd1ac0a53ec --- /dev/null +++ b/packages/contracts/src/assets.ts @@ -0,0 +1,38 @@ +import * as Schema from "effect/Schema"; + +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const ASSET_PATH_MAX_LENGTH = 1024; + +export const AssetResource = Schema.Union([ + Schema.TaggedStruct("workspace-file", { + threadId: ThreadId, + path: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + }), + Schema.TaggedStruct("attachment", { + attachmentId: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + }), + Schema.TaggedStruct("project-favicon", { + cwd: TrimmedNonEmptyString.check(Schema.isMaxLength(ASSET_PATH_MAX_LENGTH)), + }), +]); +export type AssetResource = typeof AssetResource.Type; + +export const AssetCreateUrlInput = Schema.Struct({ + resource: AssetResource, +}); +export type AssetCreateUrlInput = typeof AssetCreateUrlInput.Type; + +export const AssetCreateUrlResult = Schema.Struct({ + relativeUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + expiresAt: Schema.Number, +}); +export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; + +export class AssetAccessError extends Schema.TaggedErrorClass()( + "AssetAccessError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 163d5e236cd..43270efdec7 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -21,5 +21,8 @@ export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./assets.ts"; export * from "./review.ts"; +export * from "./preview.ts"; +export * from "./previewAutomation.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d8656ddf4f..6b3a922c2e7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -20,6 +20,7 @@ import type { } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; +import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -54,6 +55,31 @@ import type { } from "./terminal.ts"; import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; import * as Schema from "effect/Schema"; +import type { + DiscoveredLocalServerList, + PreviewCloseInput, + PreviewEvent, + PreviewListInput, + PreviewListResult, + PreviewNavigateInput, + PreviewOpenInput, + PreviewRefreshInput, + PreviewReportStatusInput, + PreviewSessionSnapshot, +} from "./preview.ts"; +import { + PreviewAutomationClickInput, + PreviewAutomationEvaluateInput, + PreviewAutomationOwner, + PreviewAutomationPressInput, + PreviewAutomationRequest, + PreviewAutomationResponse, + PreviewAutomationScrollInput, + PreviewAutomationSnapshot, + PreviewAutomationStatus, + PreviewAutomationTypeInput, + PreviewAutomationWaitForInput, +} from "./previewAutomation.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -402,6 +428,468 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ }); export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; +/** + * Renderer-facing snapshot of a desktop preview tab. Mirrors the main-process + * PreviewTabState shape but uses serialisable primitives only. + */ +export type DesktopPreviewNavStatus = + | { 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 DesktopPreviewTabState { + tabId: string; + webContentsId: number | null; + navStatus: DesktopPreviewNavStatus; + canGoBack: boolean; + canGoForward: boolean; + /** Current zoom factor (1.0 = 100%). */ + zoomFactor: number; + controller: "human" | "agent" | "none"; + updatedAt: string; +} + +export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed()).check( + Schema.isNonEmpty(), +); + +export const DesktopPreviewNavStatusSchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("Idle") }), + Schema.Struct({ + kind: Schema.Literal("Loading"), + url: Schema.String, + title: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("Success"), + url: Schema.String, + title: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("LoadFailed"), + url: Schema.String, + title: Schema.String, + code: Schema.Number, + description: Schema.String, + }), +]); + +export const DesktopPreviewTabStateSchema: Schema.Codec = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + webContentsId: Schema.NullOr(Schema.Int), + navStatus: DesktopPreviewNavStatusSchema, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, + zoomFactor: Schema.Number, + controller: Schema.Literals(["human", "agent", "none"]), + updatedAt: Schema.String, +}); + +export interface DesktopPreviewPointerEvent { + tabId: string; + phase: "move" | "click"; + x: number; + y: number; + sequence: number; + createdAt: string; +} + +export const DesktopPreviewPointerEventSchema: Schema.Codec = + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + phase: Schema.Literals(["move", "click"]), + x: Schema.Number, + y: Schema.Number, + sequence: Schema.Int, + createdAt: Schema.String, + }); + +/** + * Static config a renderer needs to mount a preview ``. Returned + * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer + * doesn't have to wait on three separate IPC round-trips before the webview + * can attach. + */ +export interface DesktopPreviewWebviewConfig { + /** `persist:t3code-preview` (or whatever the desktop chose). */ + partition: string; + /** + * Canonical `` string. Encodes the security + * posture (sandboxed but contextIsolation off so the picker preload can + * read the page's React DevTools hook). Always present. + */ + webPreferences: string; + /** + * Absolute `file://`-style URL to the picker preload bundle. Set to null + * when the bundle isn't present (older builds, broken install) — the + * renderer must then disable element-pick affordances. + */ + preloadUrl: string | null; +} + +export const DesktopPreviewWebviewConfigSchema: Schema.Codec = + Schema.Struct({ + partition: Schema.String, + webPreferences: Schema.String, + preloadUrl: Schema.NullOr(Schema.String), + }); + +export interface DesktopPreviewAnnotationTheme { + colorScheme: "light" | "dark"; + radius: string; + background: string; + foreground: string; + popover: string; + popoverForeground: string; + primary: string; + primaryForeground: string; + muted: string; + mutedForeground: string; + accent: string; + accentForeground: string; + border: string; + input: string; + ring: string; + fontSans: string; + fontMono: string; +} + +export const DesktopPreviewAnnotationThemeSchema: Schema.Codec = + Schema.Struct({ + colorScheme: Schema.Literals(["light", "dark"]), + radius: Schema.String, + background: Schema.String, + foreground: Schema.String, + popover: Schema.String, + popoverForeground: Schema.String, + primary: Schema.String, + primaryForeground: Schema.String, + muted: Schema.String, + mutedForeground: Schema.String, + accent: Schema.String, + accentForeground: Schema.String, + border: Schema.String, + input: Schema.String, + ring: Schema.String, + fontSans: Schema.String, + fontMono: Schema.String, + }); + +export interface DesktopPreviewRecordingFrame { + tabId: string; + data: string; + width: number; + height: number; + receivedAt: string; +} + +export const DesktopPreviewRecordingFrameSchema: Schema.Codec = + Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + data: Schema.String, + width: Schema.Number, + height: Schema.Number, + receivedAt: Schema.String, + }); + +export interface DesktopPreviewRecordingArtifact { + id: string; + tabId: string; + path: string; + mimeType: string; + sizeBytes: number; + createdAt: string; +} + +export const DesktopPreviewRecordingArtifactSchema: Schema.Codec = + Schema.Struct({ + id: Schema.String, + tabId: DesktopPreviewTabIdSchema, + path: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Int, + createdAt: Schema.String, + }); + +export interface DesktopPreviewScreenshotArtifact { + id: string; + tabId: string; + path: string; + mimeType: "image/png"; + sizeBytes: number; + createdAt: string; +} + +export const DesktopPreviewScreenshotArtifactSchema: Schema.Codec = + Schema.Struct({ + id: Schema.String, + tabId: DesktopPreviewTabIdSchema, + path: Schema.String, + mimeType: Schema.Literal("image/png"), + sizeBytes: Schema.Int, + createdAt: Schema.String, + }); + +/** + * Single stack frame captured by react-grab's `getElementContext`. We surface + * the source file/line so coding agents can jump straight to the JSX that + * produced the picked DOM node. + */ +export interface PickedElementStackFrame { + functionName: string | null; + fileName: string | null; + lineNumber: number | null; + columnNumber: number | null; +} + +export const PickedElementStackFrameSchema: Schema.Codec = Schema.Struct({ + functionName: Schema.NullOr(Schema.String), + fileName: Schema.NullOr(Schema.String), + lineNumber: Schema.NullOr(Schema.Number), + columnNumber: Schema.NullOr(Schema.Number), +}); + +/** + * A successful element pick from the preview webview. All fields are + * best-effort — pages that don't ship a React fiber tree (or aren't running + * in dev) will still produce a usable payload (selector + html preview), + * just without component / source attribution. + */ +export interface PickedElementPayload { + /** URL of the page the element was picked on. */ + pageUrl: string; + /** Optional `` of that page (best-effort). */ + pageTitle: string | null; + /** Lowercase tag name, e.g. `"button"`. */ + tagName: string; + /** CSS selector resolving back to the element on a re-render. */ + selector: string | null; + /** Truncated outer-HTML preview (matches react-grab's `htmlPreview`). */ + htmlPreview: string; + /** Nearest React component display name, or null when unavailable. */ + componentName: string | null; + /** First source-mapped stack frame (file + line of the JSX source). */ + source: PickedElementStackFrame | null; + /** Full owner-stack frames; can be empty. Useful for richer context. */ + stack: ReadonlyArray<PickedElementStackFrame>; + /** Author CSS only (UA defaults stripped) — react-grab's `styles`. */ + styles: string; + /** Wall-clock pick time as ISO-8601 string. */ + pickedAt: string; +} + +export const PickedElementPayloadSchema: Schema.Codec<PickedElementPayload> = Schema.Struct({ + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + tagName: Schema.String, + selector: Schema.NullOr(Schema.String), + htmlPreview: Schema.String, + componentName: Schema.NullOr(Schema.String), + source: Schema.NullOr(PickedElementStackFrameSchema), + stack: Schema.Array(PickedElementStackFrameSchema), + styles: Schema.String, + pickedAt: Schema.String, +}); + +export interface PreviewAnnotationRect { + x: number; + y: number; + width: number; + height: number; +} + +export const PreviewAnnotationRectSchema: Schema.Codec<PreviewAnnotationRect> = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); + +export interface PreviewAnnotationPoint { + x: number; + y: number; +} + +export const PreviewAnnotationPointSchema: Schema.Codec<PreviewAnnotationPoint> = Schema.Struct({ + x: Schema.Number, + y: Schema.Number, +}); + +export interface PreviewAnnotationElementTarget { + id: string; + element: PickedElementPayload; + rect: PreviewAnnotationRect; +} + +export const PreviewAnnotationElementTargetSchema: Schema.Codec<PreviewAnnotationElementTarget> = + Schema.Struct({ + id: Schema.String, + element: PickedElementPayloadSchema, + rect: PreviewAnnotationRectSchema, + }); + +export interface PreviewAnnotationRegionTarget { + id: string; + rect: PreviewAnnotationRect; +} + +export const PreviewAnnotationRegionTargetSchema: Schema.Codec<PreviewAnnotationRegionTarget> = + Schema.Struct({ + id: Schema.String, + rect: PreviewAnnotationRectSchema, + }); + +export interface PreviewAnnotationStrokeTarget { + id: string; + color: string; + width: number; + points: ReadonlyArray<PreviewAnnotationPoint>; + bounds: PreviewAnnotationRect; +} + +export const PreviewAnnotationStrokeTargetSchema: Schema.Codec<PreviewAnnotationStrokeTarget> = + Schema.Struct({ + id: Schema.String, + color: Schema.String, + width: Schema.Number, + points: Schema.Array(PreviewAnnotationPointSchema), + bounds: PreviewAnnotationRectSchema, + }); + +export interface PreviewAnnotationStyleChange { + targetId: string; + selector: string | null; + property: string; + previousValue: string; + value: string; +} + +export const PreviewAnnotationStyleChangeSchema: Schema.Codec<PreviewAnnotationStyleChange> = + Schema.Struct({ + targetId: Schema.String, + selector: Schema.NullOr(Schema.String), + property: Schema.String, + previousValue: Schema.String, + value: Schema.String, + }); + +export interface PreviewAnnotationScreenshot { + dataUrl: string; + width: number; + height: number; + cropRect: PreviewAnnotationRect; +} + +export const PreviewAnnotationScreenshotSchema: Schema.Codec<PreviewAnnotationScreenshot> = + Schema.Struct({ + dataUrl: Schema.String, + width: Schema.Number, + height: Schema.Number, + cropRect: PreviewAnnotationRectSchema, + }); + +/** + * A submitted preview annotation. One annotation may reference multiple DOM + * elements, freeform regions, and ink strokes. The desktop main process adds + * the screenshot after the guest preload submits the structured draft. + */ +export interface PreviewAnnotationPayload { + id: string; + pageUrl: string; + pageTitle: string | null; + comment: string; + elements: ReadonlyArray<PreviewAnnotationElementTarget>; + regions: ReadonlyArray<PreviewAnnotationRegionTarget>; + strokes: ReadonlyArray<PreviewAnnotationStrokeTarget>; + styleChanges: ReadonlyArray<PreviewAnnotationStyleChange>; + screenshot: PreviewAnnotationScreenshot | null; + createdAt: string; +} + +export const PreviewAnnotationPayloadSchema: Schema.Codec<PreviewAnnotationPayload> = Schema.Struct( + { + id: Schema.String, + pageUrl: Schema.String, + pageTitle: Schema.NullOr(Schema.String), + comment: Schema.String, + elements: Schema.Array(PreviewAnnotationElementTargetSchema), + regions: Schema.Array(PreviewAnnotationRegionTargetSchema), + strokes: Schema.Array(PreviewAnnotationStrokeTargetSchema), + styleChanges: Schema.Array(PreviewAnnotationStyleChangeSchema), + screenshot: Schema.NullOr(PreviewAnnotationScreenshotSchema), + createdAt: Schema.String, + }, +); + +export const DesktopPreviewTabInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, +}); + +export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + webContentsId: Schema.Int.check(Schema.isGreaterThan(0)), +}); + +export const DesktopPreviewNavigateInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + url: Schema.String, +}); + +export const DesktopPreviewConfigInputSchema = Schema.Struct({ + environmentId: EnvironmentId, +}); + +export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ + theme: DesktopPreviewAnnotationThemeSchema, +}); + +export const DesktopPreviewArtifactInputSchema = Schema.Struct({ + path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), +}); + +export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + mimeType: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()), + data: Schema.Uint8Array, +}); + +export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationClickInput, +}); + +export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationTypeInput, +}); + +export const DesktopPreviewAutomationPressInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationPressInput, +}); + +export const DesktopPreviewAutomationScrollInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationScrollInput, +}); + +export const DesktopPreviewAutomationEvaluateInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationEvaluateInput, +}); + +export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + input: PreviewAutomationWaitForInput, +}); + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -414,6 +902,9 @@ export interface DesktopBridge { getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<string | null>; setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise<boolean>; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise<void>; + getConnectionCatalog?: () => Promise<string | null>; + setConnectionCatalog?: (catalog: string) => Promise<boolean>; + clearConnectionCatalog?: () => Promise<void>; discoverSshHosts: () => Promise<readonly DesktopDiscoveredSshHost[]>; ensureSshEnvironment: ( target: DesktopSshEnvironmentTarget, @@ -460,6 +951,73 @@ export interface DesktopBridge { downloadUpdate: () => Promise<DesktopUpdateActionResult>; installUpdate: () => Promise<DesktopUpdateActionResult>; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** + * Desktop-only preview surface. Present iff the renderer is hosted by the + * Electron desktop build; web builds have `preview === undefined`. + */ + preview?: DesktopPreviewBridge; +} + +export interface DesktopPreviewBridge { + createTab: (tabId: string) => Promise<void>; + closeTab: (tabId: string) => Promise<void>; + registerWebview: (tabId: string, webContentsId: number) => Promise<void>; + navigate: (tabId: string, url: string) => Promise<void>; + goBack: (tabId: string) => Promise<void>; + goForward: (tabId: string) => Promise<void>; + refresh: (tabId: string) => Promise<void>; + zoomIn: (tabId: string) => Promise<void>; + zoomOut: (tabId: string) => Promise<void>; + resetZoom: (tabId: string) => Promise<void>; + /** Reload bypassing the HTTP cache. */ + hardReload: (tabId: string) => Promise<void>; + /** Open the guest webview's DevTools (detached). */ + openDevTools: (tabId: string) => Promise<void>; + /** Drop cookies + storage data for the preview partition (all tabs). */ + clearCookies: () => Promise<void>; + /** Drop the HTTP cache for the preview partition (all tabs). */ + clearCache: () => Promise<void>; + /** + * One-shot config for mounting a preview `<webview>`. Replaces three + * earlier round-trip calls (`getBrowserPartition`, `getWebviewPreferences`, + * `getPickPreloadPath`) so adding a new field here only requires touching + * the contract + main, not the renderer's mount logic. + */ + getPreviewConfig: (environmentId: EnvironmentId) => Promise<DesktopPreviewWebviewConfig>; + setAnnotationTheme: (theme: DesktopPreviewAnnotationTheme) => Promise<void>; + /** + * Activate the in-page element picker for the given tab. Resolves with + * the picked payload, or `null` when the user cancels (Escape / nav). The + * promise rejects if the picker can't be activated (no webview, etc.). + */ + pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; + /** Cancel an in-flight preview annotation session. */ + cancelPickElement: (tabId: string) => Promise<void>; + captureScreenshot: (tabId: string) => Promise<DesktopPreviewScreenshotArtifact>; + revealArtifact: (path: string) => Promise<void>; + copyArtifactToClipboard: (path: string) => Promise<void>; + recording: { + startScreencast: (tabId: string) => Promise<void>; + stopScreencast: (tabId: string) => Promise<void>; + save: ( + tabId: string, + mimeType: string, + data: Uint8Array, + ) => Promise<DesktopPreviewRecordingArtifact>; + onFrame: (listener: (frame: DesktopPreviewRecordingFrame) => void) => () => void; + }; + automation: { + status: (tabId: string) => Promise<PreviewAutomationStatus>; + snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; + click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>; + type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>; + press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>; + scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>; + evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>; + waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; + }; + onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; + onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; } /** @@ -561,6 +1119,9 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; }; + assets: { + createUrl: (input: AssetCreateUrlInput) => Promise<AssetCreateUrlResult>; + }; sourceControl: { lookupRepository: ( input: SourceControlRepositoryLookupInput, @@ -619,4 +1180,30 @@ export interface EnvironmentApi { }, ) => () => void; }; + preview: { + open: (input: typeof PreviewOpenInput.Encoded) => Promise<PreviewSessionSnapshot>; + navigate: (input: typeof PreviewNavigateInput.Encoded) => Promise<PreviewSessionSnapshot>; + refresh: (input: typeof PreviewRefreshInput.Encoded) => Promise<void>; + close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; + list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; + reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; + automation: { + connect: ( + input: { clientId: string }, + callback: (request: PreviewAutomationRequest) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; + respond: (response: PreviewAutomationResponse) => Promise<void>; + reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; + clearOwner: (input: { clientId: string }) => Promise<void>; + }; + onEvent: ( + callback: (event: PreviewEvent) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; + subscribePorts: ( + callback: (servers: DiscoveredLocalServerList) => void, + options?: { onResubscribe?: () => void }, + ) => () => void; + }; } diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 2165597ac30..19c98c390c3 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -29,6 +29,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsed.command, "terminal.toggle"); + const parsedRightPanelToggle = yield* decode(KeybindingRule, { + key: "mod+alt+b", + command: "rightPanel.toggle", + }); + assert.strictEqual(parsedRightPanelToggle.command, "rightPanel.toggle"); + const parsedClose = yield* decode(KeybindingRule, { key: "mod+w", command: "terminal.close", @@ -100,8 +106,9 @@ it.effect("parses keybindings array payload", () => const parsed = yield* decode(KeybindingsConfig, [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, ]); - assert.lengthOf(parsed, 2); + assert.lengthOf(parsed, 3); }), ); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 303d5c71c9c..4a5ffd0c3dd 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -50,9 +50,17 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA const STATIC_KEYBINDING_COMMANDS = [ "terminal.toggle", "terminal.split", + "terminal.splitVertical", "terminal.new", "terminal.close", + "rightPanel.toggle", "diff.toggle", + "preview.toggle", + "preview.refresh", + "preview.focusUrl", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", "commandPalette.toggle", "chat.new", "chat.newLocal", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 218d0de7437..46d51da371f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -194,6 +194,17 @@ export const ProjectScript = Schema.Struct({ command: TrimmedNonEmptyString, icon: ProjectScriptIcon, runOnWorktreeCreate: Schema.Boolean, + /** + * URL to open in the in-app browser preview when this script runs (or + * when the user explicitly requests a preview). Optional; only honored on + * the desktop build. + */ + previewUrl: Schema.optional(TrimmedNonEmptyString), + /** + * When true, automatically open the preview panel pointed at `previewUrl` + * the moment this script starts. Ignored without `previewUrl` or on web. + */ + autoOpenPreview: Schema.optional(Schema.Boolean), }); export type ProjectScript = typeof ProjectScript.Type; diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts new file mode 100644 index 00000000000..e4e6757b441 --- /dev/null +++ b/packages/contracts/src/preview.test.ts @@ -0,0 +1,166 @@ +import { Schema } from "effect"; +import { describe, expect, it } from "vite-plus/test"; + +import { + DiscoveredLocalServer, + PreviewEvent, + PreviewNavStatus, + PreviewSessionSnapshot, +} from "./preview.ts"; + +const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent); +const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); +const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); +const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); + +describe("PreviewNavStatus", () => { + it("decodes Idle", () => { + expect(decodeNavStatus({ _tag: "Idle" })).toEqual({ _tag: "Idle" }); + }); + + it("decodes Loading with title", () => { + expect(decodeNavStatus({ _tag: "Loading", url: "http://localhost:5173/", title: "" })).toEqual({ + _tag: "Loading", + url: "http://localhost:5173/", + title: "", + }); + }); + + it("decodes LoadFailed with code/description", () => { + expect( + decodeNavStatus({ + _tag: "LoadFailed", + url: "https://example.com/", + title: "Example", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }), + ).toEqual({ + _tag: "LoadFailed", + url: "https://example.com/", + title: "Example", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + }); + + it("rejects empty url", () => { + expect(() => decodeNavStatus({ _tag: "Loading", url: "", title: "" })).toThrow(); + }); +}); + +describe("PreviewSessionSnapshot", () => { + it("round-trips a Success snapshot", () => { + const snapshot = decodeSnapshot({ + threadId: "thread-1", + tabId: "preview-thread-1", + navStatus: { + _tag: "Success", + url: "http://localhost:5173/", + title: "Vite App", + }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + expect(snapshot.tabId).toBe("preview-thread-1"); + expect(snapshot.navStatus._tag).toBe("Success"); + }); +}); + +describe("PreviewEvent", () => { + it("decodes opened", () => { + const event = decodePreviewEvent({ + type: "opened", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + snapshot: { + threadId: "t", + tabId: "preview-t", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }); + expect(event.type).toBe("opened"); + }); + + it("decodes failed with code/description", () => { + const event = decodePreviewEvent({ + type: "failed", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + url: "https://example.com/", + title: "", + code: -105, + description: "ERR_NAME_NOT_RESOLVED", + }); + expect(event.type).toBe("failed"); + if (event.type === "failed") { + expect(event.code).toBe(-105); + } + }); + + it("decodes closed without snapshot", () => { + const event = decodePreviewEvent({ + type: "closed", + threadId: "t", + tabId: "preview-t", + createdAt: "2026-01-01T00:00:00.000Z", + }); + expect(event.type).toBe("closed"); + }); +}); + +describe("DiscoveredLocalServer", () => { + it("decodes a server with process metadata", () => { + const server = decodeServer({ + host: "localhost", + port: 5173, + url: "http://localhost:5173", + processName: "node", + pid: 12345, + terminal: null, + }); + expect(server.port).toBe(5173); + expect(server.processName).toBe("node"); + }); + + it("decodes a server without process metadata", () => { + const server = decodeServer({ + host: "localhost", + port: 3000, + url: "http://localhost:3000", + processName: null, + pid: null, + terminal: null, + }); + expect(server.processName).toBeNull(); + }); + + it("rejects invalid ports", () => { + expect(() => + decodeServer({ + host: "localhost", + port: 0, + url: "http://localhost:0", + processName: null, + pid: null, + terminal: null, + }), + ).toThrow(); + expect(() => + decodeServer({ + host: "localhost", + port: 70000, + url: "http://localhost:70000", + processName: null, + pid: null, + terminal: null, + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts new file mode 100644 index 00000000000..044b8fbbd07 --- /dev/null +++ b/packages/contracts/src/preview.ts @@ -0,0 +1,187 @@ +/** + * Preview - Schemas for the in-app browser preview surface. + * + * The preview is desktop-only (Chromium <webview>); the server tracks per-thread + * tab metadata so it survives client reconnects and multi-window. The desktop + * renderer mediates: it owns the actual <webview> and reports navigation back to + * the server via these RPCs, the server fans events to all subscribers. + * + * @module Preview + */ +import { Schema } from "effect"; +import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); +const Title = Schema.String.check(Schema.isMaxLength(512)); + +export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); +export type PreviewTabId = typeof PreviewTabId.Type; + +export const PreviewNavStatus = Schema.Union([ + Schema.TaggedStruct("Idle", {}), + Schema.TaggedStruct("Loading", { + url: Url, + title: Title, + }), + Schema.TaggedStruct("Success", { + url: Url, + title: Title, + }), + Schema.TaggedStruct("LoadFailed", { + url: Url, + title: Title, + code: Schema.Int, + description: Schema.String, + }), +]); +export type PreviewNavStatus = typeof PreviewNavStatus.Type; + +export const PreviewSessionSnapshot = Schema.Struct({ + threadId: TrimmedNonEmptyString, + tabId: PreviewTabId, + navStatus: PreviewNavStatus, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, + updatedAt: Schema.String, +}); +export type PreviewSessionSnapshot = typeof PreviewSessionSnapshot.Type; + +export const PreviewOpenInput = Schema.Struct({ + threadId: ThreadId, + /** Omit to create an empty (Idle) tab the user can type into. */ + url: Schema.optional(Url), +}); +export type PreviewOpenInput = typeof PreviewOpenInput.Type; + +export const PreviewNavigateInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, + url: Url, + resolvedTitle: Schema.optional(Title), +}); +export type PreviewNavigateInput = typeof PreviewNavigateInput.Type; + +export const PreviewReportStatusInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, + navStatus: PreviewNavStatus, + canGoBack: Schema.Boolean, + canGoForward: Schema.Boolean, +}); +export type PreviewReportStatusInput = typeof PreviewReportStatusInput.Type; + +export const PreviewRefreshInput = Schema.Struct({ + threadId: ThreadId, + tabId: PreviewTabId, +}); +export type PreviewRefreshInput = typeof PreviewRefreshInput.Type; + +export const PreviewCloseInput = Schema.Struct({ + threadId: ThreadId, + tabId: Schema.optional(PreviewTabId), +}); +export type PreviewCloseInput = typeof PreviewCloseInput.Type; + +export const PreviewListInput = Schema.Struct({ + threadId: ThreadId, +}); +export type PreviewListInput = typeof PreviewListInput.Type; + +export const PreviewListResult = Schema.Struct({ + sessions: Schema.Array(PreviewSessionSnapshot), +}); +export type PreviewListResult = typeof PreviewListResult.Type; + +const PreviewEventBaseSchema = Schema.Struct({ + threadId: TrimmedNonEmptyString, + tabId: PreviewTabId, + createdAt: Schema.String, +}); + +const PreviewOpenedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("opened"), + snapshot: PreviewSessionSnapshot, +}); + +const PreviewNavigatedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("navigated"), + snapshot: PreviewSessionSnapshot, +}); + +const PreviewFailedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("failed"), + url: Url, + title: Title, + code: Schema.Int, + description: Schema.String, +}); + +const PreviewClosedEvent = Schema.Struct({ + ...PreviewEventBaseSchema.fields, + type: Schema.Literal("closed"), +}); + +export const PreviewEvent = Schema.Union([ + PreviewOpenedEvent, + PreviewNavigatedEvent, + PreviewFailedEvent, + PreviewClosedEvent, +]); +export type PreviewEvent = typeof PreviewEvent.Type; + +/** + * A localhost server detected by the port scanner. Used to populate the + * "Local" recommendations in the empty-state of the preview panel. + */ +export const DiscoveredLocalServer = Schema.Struct({ + host: TrimmedNonEmptyString, + port: Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThan(65536)), + url: Url, + processName: Schema.NullOr(TrimmedNonEmptyString), + pid: Schema.NullOr(Schema.Int.check(Schema.isGreaterThan(0))), + terminal: Schema.NullOr( + Schema.Struct({ + threadId: ThreadId, + terminalId: TrimmedNonEmptyString, + }), + ), +}); +export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type; + +export const DiscoveredLocalServerList = Schema.Struct({ + servers: Schema.Array(DiscoveredLocalServer), + scannedAt: Schema.String, +}); +export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; + +export class PreviewSessionLookupError extends Schema.TaggedErrorClass<PreviewSessionLookupError>()( + "PreviewSessionLookupError", + { + threadId: Schema.String, + tabId: Schema.String, + }, +) { + override get message() { + return `Unknown preview session: thread=${this.threadId}, tab=${this.tabId}`; + } +} + +export class PreviewInvalidUrlError extends Schema.TaggedErrorClass<PreviewInvalidUrlError>()( + "PreviewInvalidUrlError", + { + rawUrl: Schema.String, + detail: Schema.optional(Schema.String), + }, +) { + override get message() { + return this.detail + ? `Invalid preview URL: ${this.rawUrl} (${this.detail})` + : `Invalid preview URL: ${this.rawUrl}`; + } +} + +export const PreviewError = Schema.Union([PreviewSessionLookupError, PreviewInvalidUrlError]); +export type PreviewError = typeof PreviewError.Type; diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts new file mode 100644 index 00000000000..791591a7a9b --- /dev/null +++ b/packages/contracts/src/previewAutomation.ts @@ -0,0 +1,512 @@ +import { Schema } from "effect"; + +import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { PreviewTabId } from "./preview.ts"; + +const BoundedUrl = Schema.String.check(Schema.isTrimmed()) + .check( + Schema.isNonEmpty({ + description: + "Absolute http(s) URL or a schemeless host such as t3.chat or localhost:5173. Schemeless public hosts use https; loopback hosts use http.", + }), + ) + .check(Schema.isMaxLength(2048)); +const OptionalTimeoutMs = Schema.optional( + Schema.Int.check(Schema.isGreaterThan(0)) + .check(Schema.isLessThanOrEqualTo(60_000)) + .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), +).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }); + +export const PreviewAutomationOperation = Schema.Literals([ + "status", + "open", + "navigate", + "snapshot", + "click", + "type", + "press", + "scroll", + "evaluate", + "waitFor", + "recordingStart", + "recordingStop", +]); +export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; + +export const PreviewAutomationStatus = Schema.Struct({ + available: Schema.Boolean, + visible: Schema.Boolean, + tabId: Schema.NullOr(PreviewTabId), + url: Schema.NullOr(Schema.String), + title: Schema.NullOr(Schema.String), + loading: Schema.Boolean, +}); +export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; + +export const PreviewAutomationOpenInput = Schema.Struct({ + url: Schema.optional(BoundedUrl).annotate({ + description: + "Optional initial page URL, for example https://t3.chat or localhost:5173. Omit to open a blank tab.", + }), + show: Schema.optional( + Schema.Boolean.annotate({ + description: "Whether to reveal the preview panel to the human. Defaults to true.", + }), + ), + reuseExistingTab: Schema.optional( + Schema.Boolean.annotate({ + description: + "Reuse the thread's active browser tab when available. Defaults to true; set false to create a new tab.", + }), + ), +}).annotate({ + description: + "Opens the collaborative browser for the current thread. Use preview_navigate afterward when readiness waiting matters.", +}); +export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; + +export const BrowserNavigationTarget = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("url").annotate({ + description: "Selects direct URL navigation.", + }), + url: BoundedUrl.annotate({ + description: "Direct website URL.", + }), + }), + Schema.Struct({ + kind: Schema.Literal("environment-port").annotate({ + description: "Selects a dev-server port relative to the current execution environment.", + }), + port: Schema.Int.check(Schema.isGreaterThan(0)) + .check(Schema.isLessThan(65_536)) + .annotate({ description: "Dev-server TCP port inside the current environment." }), + protocol: Schema.optional( + Schema.Literals(["http", "https"]).annotate({ + description: "Dev-server protocol. Defaults to http.", + }), + ), + path: Schema.optional( + Schema.String.annotate({ + description: "Optional path, query, and fragment, for example /settings?tab=account.", + }), + ), + }), +]); +export type BrowserNavigationTarget = typeof BrowserNavigationTarget.Type; + +export const PreviewAutomationNavigateInput = Schema.Struct({ + url: Schema.optional(BoundedUrl).annotate({ + description: + "Website URL, for example https://t3.chat. Use this for public pages and directly reachable URLs.", + }), + target: Schema.optional( + BrowserNavigationTarget.annotate({ + description: + "Environment-relative target. Prefer {kind:'environment-port',port:5173} for a dev server in the current environment.", + }), + ).annotate({ + description: + "Environment-relative target. Prefer {kind:'environment-port',port:5173} for a dev server in the current environment.", + }), + readiness: Schema.optional( + Schema.Literals(["load", "domContentLoaded", "none"]).annotate({ + description: + "Readiness milestone before returning. 'load' waits for loading to stop (default), 'domContentLoaded' waits for an interactive document, and 'none' returns immediately.", + }), + ).annotate({ + description: + "Readiness milestone before returning. 'load' is the default; use 'none' only when a later wait call will verify the page.", + }), + timeoutMs: OptionalTimeoutMs, +}) + .check( + Schema.makeFilter( + (input) => + Number(input.url !== undefined) + Number(input.target !== undefined) === 1 || + "Provide exactly one of url or target.", + ), + ) + .annotate({ + description: + "Navigates the active browser tab. Provide exactly one of url or target; for most public pages use url.", + }); +export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; + +const Locator = TrimmedNonEmptyString.annotate({ + description: + "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", +}); + +const LegacySelector = TrimmedNonEmptyString.annotate({ + description: + "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", +}); + +export const PreviewAutomationClickInput = Schema.Struct({ + selector: Schema.optional(LegacySelector).annotate({ + description: + "Legacy CSS selector such as button[type='submit']. Prefer locator for resilient role/text targeting.", + }), + locator: Schema.optional(Locator).annotate({ + description: + "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", + }), + x: Schema.optional( + Schema.Finite.annotate({ + description: "Viewport-relative X coordinate in CSS pixels. Must be paired with y.", + }), + ), + y: Schema.optional( + Schema.Finite.annotate({ + description: "Viewport-relative Y coordinate in CSS pixels. Must be paired with x.", + }), + ), + timeoutMs: OptionalTimeoutMs, +}) + .check( + Schema.makeFilter((input) => { + const selectorModes = + Number(input.selector !== undefined) + Number(input.locator !== undefined); + const hasX = input.x !== undefined; + const hasY = input.y !== undefined; + if (hasX !== hasY) return "Coordinates require both x and y."; + const coordinateModes = hasX && hasY ? 1 : 0; + return selectorModes + coordinateModes === 1 || "Provide exactly one click target."; + }), + ) + .annotate({ + description: + "Clicks one target. Provide exactly one of locator, selector, or the x/y coordinate pair.", + }); +export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; + +export const PreviewAutomationTypeInput = Schema.Struct({ + text: Schema.String.annotate({ description: "Literal text to insert." }), + selector: Schema.optional(LegacySelector).annotate({ + description: "Legacy CSS selector for the input. Prefer locator.", + }), + locator: Schema.optional(Locator).annotate({ + description: + "Playwright selector for the input, for example role=textbox[name='Message'] or textarea[placeholder*='Message'].", + }), + clear: Schema.optional( + Schema.Boolean.annotate({ + description: "Clear the existing input value before inserting text. Defaults to false.", + }), + ), + timeoutMs: OptionalTimeoutMs, +}) + .check( + Schema.makeFilter( + (input) => + !(input.selector !== undefined && input.locator !== undefined) || + "Provide at most one of selector or locator.", + ), + ) + .annotate({ + description: + "Types into locator/selector, or into the currently focused element when neither target is provided.", + }); +export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; + +export const PreviewAutomationPressInput = Schema.Struct({ + key: Schema.String.check(Schema.isTrimmed()) + .check( + Schema.isNonEmpty({ + description: + "Keyboard key name such as Enter, Escape, Tab, ArrowDown, Backspace, or a single character.", + }), + ) + .annotateKey({ + description: + "Keyboard key name such as Enter, Escape, Tab, ArrowDown, Backspace, or a single character.", + }), + modifiers: Schema.optional( + Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"])).annotate({ + description: "Modifier keys held while pressing key.", + }), + ), +}).annotate({ description: "Presses one keyboard key in the active browser tab." }); +export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; + +export const PreviewAutomationScrollInput = Schema.Struct({ + deltaX: Schema.optional( + Schema.Finite.annotate({ + description: "Horizontal scroll delta in CSS pixels. Positive scrolls right. Defaults to 0.", + }), + ), + deltaY: Schema.optional( + Schema.Finite.annotate({ + description: "Vertical scroll delta in CSS pixels. Positive scrolls down. Defaults to 0.", + }), + ), + selector: Schema.optional(LegacySelector).annotate({ + description: "Legacy CSS selector for a scrollable container. Omit to scroll the viewport.", + }), + locator: Schema.optional(Locator).annotate({ + description: "Playwright selector for a scrollable container. Omit to scroll the viewport.", + }), +}) + .check( + Schema.makeFilter((input) => { + if (input.selector !== undefined && input.locator !== undefined) { + return "Provide at most one of selector or locator."; + } + return ( + input.deltaX !== undefined || input.deltaY !== undefined || "Provide deltaX or deltaY." + ); + }), + ) + .annotate({ + description: + "Scrolls the viewport, or a locator/selector container. Provide deltaX, deltaY, or both.", + }); +export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; + +export const PreviewAutomationEvaluateInput = Schema.Struct({ + expression: Schema.String.check(Schema.isTrimmed()) + .check( + Schema.isNonEmpty({ + description: + "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", + }), + ) + .check(Schema.isMaxLength(64_000)) + .annotateKey({ + description: + "JavaScript expression evaluated in the page's main frame, for example document.title or (() => ({href: location.href}))().", + }), + awaitPromise: Schema.optional( + Schema.Boolean.annotate({ description: "Await a returned Promise. Defaults to true." }), + ), + returnByValue: Schema.optional( + Schema.Boolean.annotate({ + description: + "Serialize and return the value instead of a remote object reference. Defaults to true.", + }), + ), +}).annotate({ + description: + "Evaluates JavaScript in the page. Prefer snapshot and semantic actions; use evaluate for inspection or unsupported interactions.", +}); +export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; + +export const PreviewAutomationWaitForInput = Schema.Struct({ + selector: Schema.optional(LegacySelector).annotate({ + description: "Legacy CSS selector that must match an element. Prefer locator.", + }), + locator: Schema.optional(Locator).annotate({ + description: + "Playwright selector that must match an element, for example role=button[name='Send'].", + }), + text: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Case-sensitive substring that must appear in visible document text.", + }), + ).annotate({ + description: "Case-sensitive substring that must appear in visible document text.", + }), + urlIncludes: Schema.optional( + TrimmedNonEmptyString.annotate({ + description: "Substring that must appear in the current absolute URL.", + }), + ).annotate({ description: "Substring that must appear in the current absolute URL." }), + timeoutMs: OptionalTimeoutMs, +}) + .check( + Schema.makeFilter((input) => { + if (input.selector !== undefined && input.locator !== undefined) { + return "Provide at most one of selector or locator."; + } + return ( + input.selector !== undefined || + input.locator !== undefined || + input.text !== undefined || + input.urlIncludes !== undefined || + "Provide at least one wait condition." + ); + }), + ) + .annotate({ + description: + "Waits until all provided conditions match. Use after click/type when the page changes asynchronously.", + }); +export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; + +export const PreviewAutomationElement = Schema.Struct({ + tag: Schema.String, + role: Schema.NullOr(Schema.String), + name: Schema.String, + selector: Schema.String, + x: Schema.Number, + y: Schema.Number, + width: Schema.Number, + height: Schema.Number, +}); +export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; + +export const PreviewAutomationConsoleEntry = Schema.Struct({ + level: Schema.String, + text: Schema.String, + timestamp: Schema.String, + source: Schema.optional(Schema.String), +}); +export type PreviewAutomationConsoleEntry = typeof PreviewAutomationConsoleEntry.Type; + +export const PreviewAutomationNetworkEntry = Schema.Struct({ + url: Schema.String, + method: Schema.String, + status: Schema.NullOr(Schema.Number), + failed: Schema.Boolean, + errorText: Schema.optional(Schema.String), + timestamp: Schema.String, +}); +export type PreviewAutomationNetworkEntry = typeof PreviewAutomationNetworkEntry.Type; + +export const PreviewAutomationActionEvent = Schema.Struct({ + id: Schema.String, + action: Schema.String, + status: Schema.Literals(["running", "succeeded", "failed", "interrupted"]), + startedAt: Schema.String, + completedAt: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); +export type PreviewAutomationActionEvent = typeof PreviewAutomationActionEvent.Type; + +export const PreviewAutomationSnapshot = Schema.Struct({ + url: Schema.String, + title: Schema.String, + loading: Schema.Boolean, + visibleText: Schema.String, + interactiveElements: Schema.Array(PreviewAutomationElement), + accessibilityTree: Schema.Unknown, + consoleEntries: Schema.Array(PreviewAutomationConsoleEntry), + networkEntries: Schema.Array(PreviewAutomationNetworkEntry), + actionTimeline: Schema.Array(PreviewAutomationActionEvent), + screenshot: Schema.Struct({ + mimeType: Schema.Literal("image/png"), + data: Schema.String, + width: Schema.Int, + height: Schema.Int, + }), +}); +export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; + +export const PreviewAutomationRecordingStatus = Schema.Struct({ + tabId: PreviewTabId, + recording: Schema.Boolean, + startedAt: Schema.NullOr(Schema.String), +}); +export type PreviewAutomationRecordingStatus = typeof PreviewAutomationRecordingStatus.Type; + +export const PreviewAutomationRecordingArtifact = Schema.Struct({ + id: Schema.String, + tabId: PreviewTabId, + path: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Int, + createdAt: Schema.String, +}); +export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; + +export const PreviewAutomationOwner = Schema.Struct({ + clientId: TrimmedNonEmptyString, + environmentId: EnvironmentId, + threadId: ThreadId, + tabId: Schema.NullOr(PreviewTabId), + visible: Schema.Boolean, + supportsAutomation: Schema.Boolean, + focusedAt: Schema.String, +}); +export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type; + +export const PreviewAutomationRequest = Schema.Struct({ + requestId: TrimmedNonEmptyString, + threadId: ThreadId, + tabId: Schema.optional(PreviewTabId), + operation: PreviewAutomationOperation, + input: Schema.Unknown, + timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), +}); +export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type; + +export const PreviewAutomationResponse = Schema.Struct({ + requestId: TrimmedNonEmptyString, + ok: Schema.Boolean, + result: Schema.optional(Schema.Unknown), + error: Schema.optional( + Schema.Struct({ + _tag: TrimmedNonEmptyString, + message: Schema.String, + detail: Schema.optional(Schema.Unknown), + }), + ), +}); +export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; + +export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationUnavailableError>()( + "PreviewAutomationUnavailableError", + { message: Schema.String }, +) {} + +export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( + "PreviewAutomationNoFocusedOwnerError", + { message: Schema.String }, +) {} + +export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass<PreviewAutomationUnsupportedClientError>()( + "PreviewAutomationUnsupportedClientError", + { message: Schema.String }, +) {} + +export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTabNotFoundError>()( + "PreviewAutomationTabNotFoundError", + { message: Schema.String }, +) {} + +export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()( + "PreviewAutomationTimeoutError", + { message: Schema.String }, +) {} + +export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass<PreviewAutomationControlInterruptedError>()( + "PreviewAutomationControlInterruptedError", + { message: Schema.String }, +) {} + +export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( + "PreviewAutomationExecutionError", + { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, +) {} + +export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()( + "PreviewAutomationInvalidSelectorError", + { message: Schema.String, selector: Schema.String }, +) {} + +export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( + "PreviewAutomationResultTooLargeError", + { message: Schema.String, maximumBytes: Schema.Int }, +) {} + +export const PreviewAutomationError = Schema.Union([ + PreviewAutomationUnavailableError, + PreviewAutomationNoFocusedOwnerError, + PreviewAutomationUnsupportedClientError, + PreviewAutomationTabNotFoundError, + PreviewAutomationTimeoutError, + PreviewAutomationControlInterruptedError, + PreviewAutomationExecutionError, + PreviewAutomationInvalidSelectorError, + PreviewAutomationResultTooLargeError, +]); +export type PreviewAutomationError = typeof PreviewAutomationError.Type; + +export const PreviewUrlResolution = Schema.Struct({ + requestedUrl: Schema.String, + resolvedUrl: Schema.String, + resolutionKind: Schema.Literals(["direct", "direct-private-network"]), + environmentId: EnvironmentId, +}); +export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657..b1dc36651e3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,6 +13,7 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import { GitActionProgressEvent, VcsSwitchRefInput, @@ -85,6 +86,25 @@ import { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal.ts"; +import { + DiscoveredLocalServerList, + PreviewCloseInput, + PreviewError, + PreviewEvent, + PreviewListInput, + PreviewListResult, + PreviewNavigateInput, + PreviewOpenInput, + PreviewRefreshInput, + PreviewReportStatusInput, + PreviewSessionSnapshot, +} from "./preview.ts"; +import { + PreviewAutomationError, + PreviewAutomationOwner, + PreviewAutomationRequest, + PreviewAutomationResponse, +} from "./previewAutomation.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -129,6 +149,7 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + assetsCreateUrl: "assets.createUrl", // VCS methods vcsPull: "vcs.pull", @@ -157,6 +178,18 @@ export const WS_METHODS = { terminalRestart: "terminal.restart", terminalClose: "terminal.close", + // Preview methods + previewOpen: "preview.open", + previewNavigate: "preview.navigate", + previewRefresh: "preview.refresh", + previewClose: "preview.close", + previewList: "preview.list", + previewReportStatus: "preview.reportStatus", + previewAutomationConnect: "previewAutomation.connect", + previewAutomationRespond: "previewAutomation.respond", + previewAutomationReportOwner: "previewAutomation.reportOwner", + previewAutomationClearOwner: "previewAutomation.clearOwner", + // Server meta serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", @@ -184,6 +217,8 @@ export const WS_METHODS = { subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", + subscribePreviewEvents: "subscribePreviewEvents", + subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", subscribeAuthAccess: "subscribeAuthAccess", @@ -332,6 +367,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { + payload: AssetCreateUrlInput, + success: AssetCreateUrlResult, + error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -454,6 +495,78 @@ export const WsTerminalCloseRpc = Rpc.make(WS_METHODS.terminalClose, { error: Schema.Union([TerminalError, EnvironmentAuthorizationError]), }); +export const WsPreviewOpenRpc = Rpc.make(WS_METHODS.previewOpen, { + payload: PreviewOpenInput, + success: PreviewSessionSnapshot, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewNavigateRpc = Rpc.make(WS_METHODS.previewNavigate, { + payload: PreviewNavigateInput, + success: PreviewSessionSnapshot, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewRefreshRpc = Rpc.make(WS_METHODS.previewRefresh, { + payload: PreviewRefreshInput, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewCloseRpc = Rpc.make(WS_METHODS.previewClose, { + payload: PreviewCloseInput, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewListRpc = Rpc.make(WS_METHODS.previewList, { + payload: PreviewListInput, + success: PreviewListResult, + error: EnvironmentAuthorizationError, +}); + +export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { + payload: PreviewReportStatusInput, + error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { + payload: Schema.Struct({ clientId: Schema.String }), + success: PreviewAutomationRequest, + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), + stream: true, +}); + +export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { + payload: PreviewAutomationResponse, + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, { + payload: PreviewAutomationOwner, + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), +}); + +export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { + payload: Schema.Struct({ clientId: Schema.String }), + error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), +}); + +export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { + payload: Schema.Struct({}), + success: PreviewEvent, + error: EnvironmentAuthorizationError, + stream: true, +}); + +export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( + WS_METHODS.subscribeDiscoveredLocalServers, + { + payload: Schema.Struct({}), + success: DiscoveredLocalServerList, + error: EnvironmentAuthorizationError, + stream: true, + }, +); + export const WsOrchestrationDispatchCommandRpc = Rpc.make( ORCHESTRATION_WS_METHODS.dispatchCommand, { @@ -567,6 +680,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, @@ -589,6 +703,18 @@ export const WsRpcGroup = RpcGroup.make( WsTerminalCloseRpc, WsSubscribeTerminalEventsRpc, WsSubscribeTerminalMetadataRpc, + WsPreviewOpenRpc, + WsPreviewNavigateRpc, + WsPreviewRefreshRpc, + WsPreviewCloseRpc, + WsPreviewListRpc, + WsPreviewReportStatusRpc, + WsPreviewAutomationConnectRpc, + WsPreviewAutomationRespondRpc, + WsPreviewAutomationReportOwnerRpc, + WsPreviewAutomationClearOwnerRpc, + WsSubscribePreviewEventsRpc, + WsSubscribeDiscoveredLocalServersRpc, WsSubscribeServerConfigRpc, WsSubscribeServerLifecycleRpc, WsSubscribeAuthAccessRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index fe405dd0651..52ec171482b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -143,6 +143,10 @@ "types": "./src/composerTrigger.ts", "import": "./src/composerTrigger.ts" }, + "./composerInlineTokens": { + "types": "./src/composerInlineTokens.ts", + "import": "./src/composerInlineTokens.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" @@ -154,6 +158,10 @@ "./relayTracing": { "types": "./src/relayTracing.ts", "import": "./src/relayTracing.ts" + }, + "./preview": { + "types": "./src/preview.ts", + "import": "./src/preview.ts" } }, "scripts": { diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index e165d944b56..93a1649b25b 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -81,9 +81,9 @@ it.layer(NetService.layer)("NetService", (it) => { }), ); - it.effect("findAvailablePort falls back when preferred is occupied", () => + it.effect("findAvailablePort falls back when a wildcard listener occupies IPv4", () => Effect.acquireUseRelease( - openServer(), + openServer("0.0.0.0"), (server) => Effect.gen(function* () { const net = yield* NetService.NetService; diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 0a3c6283756..d7713a72612 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -28,40 +28,6 @@ const closeServer = (server: NodeNet.Server) => { } }; -const tryReservePort = (port: number): Effect.Effect<number, NetError> => - Effect.callback<number, NetError>((resume) => { - const server = NodeNet.createServer(); - let settled = false; - - const settle = (effect: Effect.Effect<number, NetError>) => { - if (settled) return; - settled = true; - resume(effect); - }; - - server.unref(); - - server.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Could not find an available port.", cause }))); - }); - - server.listen(port, () => { - const address = server.address(); - const resolved = typeof address === "object" && address !== null ? address.port : 0; - server.close(() => { - if (resolved > 0) { - settle(Effect.succeed(resolved)); - return; - } - settle(Effect.fail(new NetError({ message: "Could not find an available port." }))); - }); - }); - - return Effect.sync(() => { - closeServer(server); - }); - }); - export interface NetServiceShape { /** * Returns true when a TCP server can bind to {host, port}. @@ -131,6 +97,53 @@ export const make = () => { }); }); + const hasListenerOnHost = (port: number, host: string): Effect.Effect<boolean> => + Effect.callback<boolean>((resume) => { + const socket = NodeNet.createConnection({ host, port }); + let settled = false; + + const settle = (value: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resume(Effect.succeed(value)); + }; + + socket.unref(); + socket.setTimeout(250); + socket.once("connect", () => { + settle(true); + }); + socket.once("error", () => { + settle(false); + }); + socket.once("timeout", () => { + settle(false); + }); + + return Effect.sync(() => { + socket.destroy(); + }); + }); + + const isPortAvailableOnLoopback = (port: number): Effect.Effect<boolean> => + Effect.gen(function* () { + const hasListener = yield* Effect.zipWith( + hasListenerOnHost(port, "127.0.0.1"), + hasListenerOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 || ipv6, + ); + if (hasListener) { + return false; + } + + return yield* Effect.zipWith( + canListenOnHost(port, "127.0.0.1"), + canListenOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 && ipv6, + ); + }); + /** * Reserve an ephemeral loopback port and release it immediately. * Returns the reserved port number. @@ -169,15 +182,15 @@ export const make = () => { return { canListenOnHost, - isPortAvailableOnLoopback: (port) => - Effect.zipWith( - canListenOnHost(port, "127.0.0.1"), - canListenOnHost(port, "::1"), - (ipv4, ipv6) => ipv4 && ipv6, - ), + isPortAvailableOnLoopback, reserveLoopbackPort, findAvailablePort: (preferred) => - Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), + Effect.gen(function* () { + if (preferred > 0 && (yield* isPortAvailableOnLoopback(preferred))) { + return preferred; + } + return yield* reserveLoopbackPort(); + }), } satisfies NetServiceShape; }; diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts new file mode 100644 index 00000000000..f99d0b6654e --- /dev/null +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; + +describe("collectComposerInlineTokens", () => { + it("collects file links, mentions, and skills with source ranges", () => { + const text = "Use $ui and inspect [Chat.tsx](src/Chat.tsx) with @AGENTS.md please"; + + expect(collectComposerInlineTokens(text)).toEqual([ + { + type: "skill", + value: "ui", + source: "$ui", + start: 4, + end: 7, + }, + { + type: "mention", + value: "src/Chat.tsx", + source: "[Chat.tsx](src/Chat.tsx)", + start: 20, + end: 44, + }, + { + type: "mention", + value: "AGENTS.md", + source: "@AGENTS.md", + start: 50, + end: 60, + }, + ]); + }); + + it("does not convert incomplete trailing tokens", () => { + expect(collectComposerInlineTokens("Use $ui")).toEqual([]); + expect(collectComposerInlineTokens("Inspect @AGENTS.md")).toEqual([]); + }); + + it("keeps the delimiter after a token outside its source range", () => { + const text = "Inspect [package.json](package.json) next"; + + expect(collectComposerInlineTokens(text)).toEqual([ + { + type: "mention", + value: "package.json", + source: "[package.json](package.json)", + start: 8, + end: 36, + }, + ]); + expect(text.slice(36)).toBe(" next"); + }); + + it("preserves a confirmed pill when only its trailing delimiter is removed", () => { + const withDelimiter = "[package.json](package.json) "; + const confirmed = collectComposerInlineTokens(withDelimiter); + + expect( + collectComposerInlineTokens(withDelimiter.trimEnd(), { preserveTrailingFrom: confirmed }), + ).toEqual([ + { + type: "mention", + value: "package.json", + source: "[package.json](package.json)", + start: 0, + end: 28, + }, + ]); + }); + + it("does not preserve a pill after its source is edited", () => { + const confirmed = collectComposerInlineTokens("[package.json](package.json) "); + + expect( + collectComposerInlineTokens("[package.json](package-json)", { + preserveTrailingFrom: confirmed, + }), + ).toEqual([]); + }); + + it("ignores normal web links", () => { + expect(collectComposerInlineTokens("Read [docs](https://example.com) first")).toEqual([]); + }); +}); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts new file mode 100644 index 00000000000..aa5e67d6fc8 --- /dev/null +++ b/packages/shared/src/composerInlineTokens.ts @@ -0,0 +1,118 @@ +export type ComposerInlineToken = + | { + readonly type: "mention"; + readonly value: string; + readonly source: string; + readonly start: number; + readonly end: number; + } + | { + readonly type: "skill"; + readonly value: string; + readonly source: string; + readonly start: number; + readonly end: number; + }; + +export interface CollectComposerInlineTokensOptions { + readonly preserveTrailingFrom?: ReadonlyArray<ComposerInlineToken>; +} + +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; +const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; +const FILE_LINK_TOKEN_REGEX = /(^|\s)\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)(?=\s)/g; +const URI_SCHEME_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*:/; +const WINDOWS_DRIVE_PATH_REGEX = /^[A-Za-z]:[\\/]/; + +function collectMentionTokens(text: string): ComposerInlineToken[] { + const matches: ComposerInlineToken[] = []; + + for (const match of text.matchAll(FILE_LINK_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const label = (match[2] ?? "").replace(/\\(.)/g, "$1"); + const encodedPath = match[3] ?? ""; + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + // Preserve malformed source rather than dropping a user-authored token. + } + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + const basename = separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + const hasExternalScheme = URI_SCHEME_REGEX.test(path) && !WINDOWS_DRIVE_PATH_REGEX.test(path); + if (!path || hasExternalScheme || label !== basename) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "mention", + value: path, + source: text.slice(start, end), + start, + end, + }); + } + + for (const match of text.matchAll(MENTION_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const quotedPath = match[2]; + const path = quotedPath !== undefined ? quotedPath.replace(/\\(.)/g, "$1") : (match[3] ?? ""); + if (!path) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "mention", + value: path, + source: text.slice(start, end), + start, + end, + }); + } + + return matches; +} + +export function collectComposerInlineTokens( + text: string, + options: CollectComposerInlineTokensOptions = {}, +): ReadonlyArray<ComposerInlineToken> { + const matches = collectMentionTokens(text); + + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const fullMatch = match[0]; + const prefix = match[1] ?? ""; + const value = match[2] ?? ""; + if (!value) { + continue; + } + const start = (match.index ?? 0) + prefix.length; + const end = start + fullMatch.length - prefix.length; + matches.push({ + type: "skill", + value, + source: text.slice(start, end), + start, + end, + }); + } + + for (const token of options.preserveTrailingFrom ?? []) { + if ( + token.end === text.length && + text.slice(token.start, token.end) === token.source && + !matches.some( + (match) => + match.type === token.type && match.start === token.start && match.end === token.end, + ) + ) { + matches.push(token); + } + } + + return [...matches].sort((left, right) => left.start - right.start); +} diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 3cc2e913621..4abe53f2053 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -20,10 +20,19 @@ type WhenToken = export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+shift+j", command: "preview.toggle" }, + { key: "mod+r", command: "preview.refresh", when: "previewFocus" }, + { key: "mod+l", command: "preview.focusUrl", when: "previewFocus" }, + { key: "mod+=", command: "preview.zoomIn", when: "previewFocus" }, + { key: "mod++", command: "preview.zoomIn", when: "previewFocus" }, + { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, + { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts new file mode 100644 index 00000000000..6030686d3ed --- /dev/null +++ b/packages/shared/src/preview.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isLoopbackHost, + isPreviewableUrl, + newPreviewTabId, + normalizePreviewUrl, + PreviewUrlNormalizationError, +} from "./preview.ts"; + +describe("newPreviewTabId", () => { + it("returns a unique tab id every call", () => { + const a = newPreviewTabId(); + const b = newPreviewTabId(); + expect(a).not.toBe(b); + expect(a.startsWith("tab_")).toBe(true); + }); +}); + +describe("isLoopbackHost", () => { + it.each(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"])("%s is loopback", (host) => { + expect(isLoopbackHost(host)).toBe(true); + }); + + it.each(["example.com", "192.168.1.10", "10.0.0.1", ""])("%s is not loopback", (host) => { + expect(isLoopbackHost(host)).toBe(false); + }); +}); + +describe("isPreviewableUrl", () => { + it.each([ + "http://localhost:5173", + "http://127.0.0.1:3000/path", + "http://0.0.0.0:8080", + "http://[::1]:5173", + ])("%s is previewable", (url) => { + expect(isPreviewableUrl(url)).toBe(true); + }); + + it.each(["https://example.com", "ws://localhost:5173", "file:///etc/passwd", "not-a-url", ""])( + "%s is not previewable", + (url) => { + expect(isPreviewableUrl(url)).toBe(false); + }, + ); +}); + +describe("normalizePreviewUrl", () => { + it("treats bare loopback hosts as http", () => { + expect(normalizePreviewUrl("localhost:5173")).toBe("http://localhost:5173/"); + expect(normalizePreviewUrl("127.0.0.1:3000")).toBe("http://127.0.0.1:3000/"); + }); + + it("treats bare public hosts as https", () => { + expect(normalizePreviewUrl("example.com")).toBe("https://example.com/"); + }); + + it("respects explicit schemes", () => { + expect(normalizePreviewUrl("https://localhost:5173")).toBe("https://localhost:5173/"); + expect(normalizePreviewUrl("http://example.com/path?q=1")).toBe("http://example.com/path?q=1"); + }); + + it("rejects empty input", () => { + expect(() => normalizePreviewUrl(" ")).toThrow(PreviewUrlNormalizationError); + }); + + it("rejects unsupported protocols", () => { + expect(() => normalizePreviewUrl("ftp://example.com")).toThrow(PreviewUrlNormalizationError); + expect(() => normalizePreviewUrl("file:///etc/passwd")).toThrow(PreviewUrlNormalizationError); + }); + + it("rejects unparseable junk", () => { + expect(() => normalizePreviewUrl("http://")).toThrow(PreviewUrlNormalizationError); + }); +}); diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts new file mode 100644 index 00000000000..cc5a765ddcb --- /dev/null +++ b/packages/shared/src/preview.ts @@ -0,0 +1,91 @@ +/** + * Pure URL helpers shared between the preview server, desktop main process, + * and web renderer. Centralising these guarantees the four call sites agree + * on what counts as "loopback" and how to normalise a free-form URL string. + */ + +const TAB_ID_PREFIX = "tab_"; +let nextPreviewTabSequence = 0; + +/** + * Generate a fresh preview tab id. Lives in shared (not contracts) because + * the contracts package is schema-only — runtime helpers belong here. + */ +export function newPreviewTabId(): string { + nextPreviewTabSequence += 1; + return `${TAB_ID_PREFIX}${nextPreviewTabSequence.toString(36)}`; +} + +const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); + +/** Internal — used by `lsof` parsing where the host string is wire-formatted. */ +export const LSOF_LOCAL_HOST_TOKENS: ReadonlySet<string> = new Set([ + ...LOOPBACK_HOSTS, + "*", + "[::]", + "[::1]", +]); + +const LOOPBACK_PREFIX_PATTERN = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\])(?::|\/|$)/i; + +export function isLoopbackHost(host: string): boolean { + if (LOOPBACK_HOSTS.has(host)) return true; + if (host === "[::1]") return true; + return false; +} + +/** True when a raw URL string looks like a loopback dev URL we can preview. */ +export function isPreviewableUrl(rawUrl: string): boolean { + try { + const parsed = new URL(rawUrl); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + return isLoopbackHost(parsed.hostname); + } catch { + return false; + } +} + +export class PreviewUrlNormalizationError extends Error { + readonly rawUrl: string; + readonly detail: string; + constructor(rawUrl: string, detail: string) { + super(`Invalid preview URL: ${rawUrl} (${detail})`); + this.name = "PreviewUrlNormalizationError"; + this.rawUrl = rawUrl; + this.detail = detail; + } +} + +/** + * Normalise a free-form URL string into a fully-qualified `http(s)://` URL. + * + * - Bare loopback hosts (`localhost`, `localhost:5173`) become `http://...`. + * - Bare public hosts (`example.com`) become `https://...`. + * - Already-qualified URLs are validated and returned as `URL.href`. + * + * Throws `PreviewUrlNormalizationError` for empty, unparseable, or + * unsupported-protocol inputs. + */ +export function normalizePreviewUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + if (trimmed.length === 0) { + throw new PreviewUrlNormalizationError(rawUrl, "empty"); + } + const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); + const candidate = trimmed.includes("://") + ? trimmed + : `${useHttp ? "http" : "https"}://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch (cause) { + throw new PreviewUrlNormalizationError( + rawUrl, + cause instanceof Error ? cause.message : "unparseable", + ); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new PreviewUrlNormalizationError(rawUrl, `unsupported protocol ${parsed.protocol}`); + } + return parsed.href; +} diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 6005856d9d5..ecf035534ef 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -1,5 +1,7 @@ +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Tracer from "effect/Tracer"; @@ -39,6 +41,70 @@ export const withRelayClientTracing = <A, E, R>( ), ); +function traceSafeError(value: unknown): Error { + const message = + value instanceof Error + ? value.message + : typeof value === "object" && + value !== null && + "message" in value && + typeof value.message === "string" + ? value.message + : String(value); + const error = new Error(message); + if (value instanceof Error) { + error.name = value.name; + if (value.stack !== undefined) { + error.stack = value.stack; + } + } else if ( + typeof value === "object" && + value !== null && + "name" in value && + typeof value.name === "string" + ) { + error.name = value.name; + } + return error; +} + +function traceSafeExit(exit: Exit.Exit<unknown, unknown>): Exit.Exit<unknown, unknown> { + if (Exit.isSuccess(exit)) { + return exit; + } + return Exit.failCause( + Cause.fromReasons( + exit.cause.reasons.map((reason) => { + if (Cause.isFailReason(reason)) { + return Cause.makeFailReason(traceSafeError(reason.error)); + } + if (Cause.isDieReason(reason)) { + return Cause.makeDieReason(traceSafeError(reason.defect)); + } + return reason; + }), + ), + ); +} + +function nonInterferingTracer(delegate: Tracer.Tracer): Tracer.Tracer { + return Tracer.make({ + span(options) { + const span = delegate.span(options); + const end = span.end.bind(span); + span.end = (endTime, exit) => { + try { + end(endTime, traceSafeExit(exit)); + } catch { + // Telemetry is best-effort and must never change application behavior. + } + }; + return span; + }, + ...(delegate.context ? { context: delegate.context } : {}), + }); +} + export function makeRelayClientTracingLayer( config: RelayClientTracingConfig | null, resource: RelayClientTracingResource, @@ -64,7 +130,8 @@ export function makeRelayClientTracingLayer( }, }).pipe(Layer.provide(OtlpSerialization.layerJson)); - return Layer.effect(RelayClientTracer, Tracer.Tracer.pipe(Effect.map(Option.some))).pipe( - Layer.provide(tracerLayer), - ); + return Layer.effect( + RelayClientTracer, + Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.map(Option.some)), + ).pipe(Layer.provide(tracerLayer)); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d563a2fd5..31ad12e4164 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,12 @@ importers: electron-updater: specifier: ^6.6.2 version: 6.8.3 + playwright-core: + specifier: 1.60.0 + version: 1.60.0 + react-grab: + specifier: ^0.1.32 + version: 0.1.44(react@19.2.6) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -141,6 +147,9 @@ importers: electron-builder: specifier: 26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + tailwindcss: + specifier: ^4.0.0 + version: 4.3.0 vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -167,8 +176,8 @@ importers: specifier: ^0.7.1 version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': - specifier: ^3.3.0 - version: 3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: ^3.4.1 + version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0) @@ -211,6 +220,9 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@t3tools/mobile-markdown-text': + specifier: file:./modules/t3-markdown-text + version: file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -232,6 +244,9 @@ importers: expo: specifier: ^56.0.0 version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-asset: + specifier: ~56.0.15 + version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.12 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -271,6 +286,9 @@ importers: expo-linking: specifier: ~56.0.12 version: 56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-network: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.8)(react@19.2.3) expo-notifications: specifier: ~56.0.14 version: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) @@ -436,11 +454,11 @@ importers: specifier: ^1.4.1 version: 1.5.0(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/clerk-js': - specifier: ^6.13.0 - version: 6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^6.16.0 + version: 6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@clerk/react': - specifier: ^6.7.2 - version: 6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^6.9.0 + version: 6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -596,8 +614,8 @@ importers: infra/relay: dependencies: '@clerk/backend': - specifier: 3.4.14 - version: 3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.6.1 + version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) @@ -1488,16 +1506,16 @@ packages: resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} engines: {node: '>= 20.12.0'} - '@clerk/backend@3.4.14': - resolution: {integrity: sha512-0iaMT7k4wDk31QVC3HMaoeVFttblwsCECTHKNQpbRzIyD8j2gHdKEw/FNjffoyqyBqPw869IQlk1YokUlwVAqQ==} + '@clerk/backend@3.6.1': + resolution: {integrity: sha512-LkfekzF/0UMXacX+17xy3ExRraO0mm+thXejC8Q32gWHd1wLdxK3YXDsLDF00E1r1InWBKIt2ZOxs6hTwZPJjA==} engines: {node: '>=20.9.0'} - '@clerk/clerk-js@6.14.0': - resolution: {integrity: sha512-xreDPw31OIk/VQj36qdgjzc4Rk2HwMar25nOu/ts2gf7PrbhU4XQdrtnt74g4fTmSMp8xeyjzHqa9adDXVjISw==} + '@clerk/clerk-js@6.16.0': + resolution: {integrity: sha512-8xv/XDsxhOZd1n4DNIRJ2EehIRUg6UiqKAnfd0L88R2t1g6sVnLi1FInJ5i8Qyx5oY/creXx6X1AZ1V5PobRkA==} engines: {node: '>=20.9.0'} - '@clerk/expo@3.3.1': - resolution: {integrity: sha512-c4g64z5sgJoGYjK0NeasNwOMy9Di7cEjICq56BHSowdOuB+6UGtWBNw+yHzgS1gxi2kJgl7WQCmmXRsoZNWxAg==} + '@clerk/expo@3.4.1': + resolution: {integrity: sha512-gpAXsuUnsUdUD0/2XjyxaC9quF5rT+2umkmV74nBLVAFurGMMMMHvnHqrQEtZ7tH5GHNXYw5+pgmnzd1HiMQbQ==} engines: {node: '>=20.9.0'} peerDependencies: '@clerk/expo-passkeys': '>=0.0.6' @@ -1529,16 +1547,18 @@ packages: optional: true expo-web-browser: optional: true + react-dom: + optional: true - '@clerk/react@6.7.3': - resolution: {integrity: sha512-xdml8bFXbOQ/Egyp7iI1f0ksLjw5nYu2Db+mttHpJzet7PRXQ3jBEEc2c0AYhOJvIYxJifHGBsf75NM8SwlOag==} + '@clerk/react@6.9.0': + resolution: {integrity: sha512-M0QGyGS732tYBXeG+28UgElXM2TfoSZ+4mWGisC8yxJX8NjH4hEPJTAQuZmYRLNaCyGQCuzjYVQiQRC+GbDtmA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 - '@clerk/shared@4.15.0': - resolution: {integrity: sha512-uX8nfLb69m8mA6KWKWfuPSwoVNDRyUdufeCeTEZsdZxbRUsEYT/c0KWFN28IOQCtK09tpVtzrUHvW44v5Dc5OA==} + '@clerk/shared@4.17.0': + resolution: {integrity: sha512-YeQ+6zDmqyor1mPHjZx18j+LssL6Pobvid8hb7HQMioSo8sGDBEVi/Z12bs+gUhe9KbdP+ygHsKOqqeGAPuPZA==} engines: {node: '>=20.9.0'} peerDependencies: react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0 @@ -2395,6 +2415,9 @@ packages: peerDependencies: hono: ^4 + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -3432,6 +3455,10 @@ packages: '@types/react': optional: true + '@react-grab/cli@0.1.44': + resolution: {integrity: sha512-gMDYY2rw6OWajCcDlXSIgs2LC432YJXSb3Lm5yM187uhRgBYddoEVULi36h+IolX3r7jSb3ew7vn9FfI8NSo0A==} + hasBin: true + '@react-native-masked-view/masked-view@0.3.2': resolution: {integrity: sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==} peerDependencies: @@ -4068,6 +4095,17 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': + resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} + peerDependencies: + expo-asset: '*' + expo-clipboard: '*' + expo-haptics: '*' + expo-symbols: '*' + react: '*' + react-native: '*' + react-native-nitro-markdown: '*' + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': resolution: {directory: apps/mobile/modules/t3-review-diff, type: directory} @@ -4787,6 +4825,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-install@0.0.5: + resolution: {integrity: sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ==} + hasBin: true + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -5054,6 +5096,11 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bippy@0.5.41: + resolution: {integrity: sha512-jCP2pXXLhXqPrAN+iSEFZmLI4uUM4fjSqajh0K+TmM062VehfDT3ZJNkrTGyN701Z5XMejs9qAudSqkMGhSMKg==} + peerDependencies: + react: '>=17.0.1' + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -5244,10 +5291,18 @@ packages: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -5321,6 +5376,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -6065,6 +6124,12 @@ packages: peerDependencies: react-native: '*' + expo-network@56.0.5: + resolution: {integrity: sha512-zmuyO95jayDY9jyUfOAlNp9XXJrJaAOkBXXLy0TS/nh2kppj7CHirRPkQ/tf0rsxhIL3AEd9nsRTiPtNsGT9Lw==} + peerDependencies: + expo: '*' + react: '*' + expo-notifications@56.0.15: resolution: {integrity: sha512-F+OasAePiVnHaPNKI9JAYV8fg8bdBwo7Mh9R3ydBp8S21fRQyxKOSgJvj8fX/HoPFFIC6V2B+y1LJbG5Ovh/Fg==} peerDependencies: @@ -6604,6 +6669,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + image-size@1.2.1: resolution: {integrity: sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==} engines: {node: '>=16.x'} @@ -6725,6 +6794,10 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} @@ -6742,6 +6815,10 @@ packages: is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} @@ -7154,6 +7231,10 @@ packages: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} engines: {node: '>=4'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -7484,6 +7565,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -7729,6 +7814,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -7743,6 +7832,10 @@ packages: resolution: {integrity: sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==} engines: {node: '>=6'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + os-paths@7.4.0: resolution: {integrity: sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA==} engines: {node: '>= 4.0'} @@ -8136,6 +8229,15 @@ packages: peerDependencies: react: '>=17.0.0' + react-grab@0.1.44: + resolution: {integrity: sha512-bDEwBdI90ljq2lhUtPqmWis/HwYB/CvfT0m5i+P9F83Pt0Ot8o9XL8v00s9jcWzdQUlsFDzmq2FO2CHUe8JY8A==} + hasBin: true + peerDependencies: + react: '>=17.0.0' + peerDependenciesMeta: + react: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -8454,6 +8556,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retext-latin@4.0.0: resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} @@ -8757,6 +8863,10 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + stream-buffers@2.2.0: resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==} engines: {node: '>= 0.10.0'} @@ -9631,6 +9741,10 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -10582,18 +10696,18 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clerk/backend@3.4.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/backend@3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) standardwebhooks: 1.0.0 tslib: 2.8.1 transitivePeerDependencies: - react - react-dom - '@clerk/clerk-js@6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/clerk-js@6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10608,9 +10722,9 @@ snapshots: - react - react-dom - '@clerk/clerk-js@6.14.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/clerk-js@6.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@stripe/stripe-js': 5.6.0 '@swc/helpers': 0.5.21 '@tanstack/query-core': 5.100.14 @@ -10625,15 +10739,14 @@ snapshots: - react - react-dom - '@clerk/expo@3.3.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.8)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@clerk/clerk-js': 6.14.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/react': 6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/clerk-js': 6.16.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/react': 6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 @@ -10643,22 +10756,23 @@ snapshots: expo-crypto: 56.0.4(expo@56.0.8) expo-secure-store: 56.0.4(expo@56.0.8) expo-web-browser: 56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-dom: 19.2.3(react@19.2.3) - '@clerk/react@6.7.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/react@6.9.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@clerk/shared': 4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) tslib: 2.8.1 - '@clerk/react@6.7.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/react@6.9.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@clerk/shared': 4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@clerk/shared': 4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tslib: 2.8.1 - '@clerk/shared@4.15.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@clerk/shared@4.17.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -10668,7 +10782,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@clerk/shared@4.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@clerk/shared@4.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/query-core': 5.100.14 dequal: 2.0.3 @@ -11621,6 +11735,8 @@ snapshots: dependencies: hono: 4.12.25 + '@iarna/toml@2.2.5': {} + '@img/colour@1.1.0': optional: true @@ -12577,6 +12693,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@react-grab/cli@0.1.44': + dependencies: + agent-install: 0.0.5 + commander: 14.0.3 + ignore: 7.0.5 + ora: 9.4.0 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + prompts: 2.4.2 + tinyexec: 1.2.4 + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 @@ -13126,6 +13253,16 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(e909ee9a8f7fbe234da80c739fca4984)': + dependencies: + expo-asset: 56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.3(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-haptics: 56.0.3(expo@56.0.8) + expo-symbols: 56.0.5(expo-font@56.0.5)(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} '@t3tools/mobile-terminal-native@file:apps/mobile/modules/t3-terminal': {} @@ -13796,6 +13933,15 @@ snapshots: agent-base@7.1.4: {} + agent-install@0.0.5: + dependencies: + '@iarna/toml': 2.2.5 + commander: 14.0.3 + jsonc-parser: 3.3.1 + picocolors: 1.1.1 + prompts: 2.4.2 + yaml: 2.9.0 + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -14229,6 +14375,10 @@ snapshots: big-integer@1.6.52: {} + bippy@0.5.41(react@19.2.6): + dependencies: + react: 19.2.6 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -14451,8 +14601,14 @@ snapshots: dependencies: restore-cursor: 4.0.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -14520,6 +14676,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@5.1.0: {} @@ -15230,6 +15388,11 @@ snapshots: dependencies: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-network@56.0.5(expo@56.0.8)(react@19.2.3): + dependencies: + expo: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react: 19.2.3 + expo-notifications@56.0.15(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) @@ -15980,6 +16143,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + image-size@1.2.1: dependencies: queue: 6.0.2 @@ -16099,6 +16264,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-interactive@2.0.0: {} + is-node-process@1.2.0: {} is-number@7.0.0: {} @@ -16109,6 +16276,8 @@ snapshots: is-property@1.0.2: {} + is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: dependencies: is-docker: 2.2.1 @@ -16442,6 +16611,11 @@ snapshots: dependencies: chalk: 2.4.2 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + long@5.3.2: {} longest-streak@3.1.0: {} @@ -17071,6 +17245,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -17308,6 +17484,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -17330,6 +17510,17 @@ snapshots: strip-ansi: 5.2.0 wcwidth: 1.0.1 + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.1 + os-paths@7.4.0: optionalDependencies: fsevents: 2.3.3 @@ -17721,6 +17912,13 @@ snapshots: dependencies: react: 19.2.3 + react-grab@0.1.44(react@19.2.6): + dependencies: + '@react-grab/cli': 0.1.44 + bippy: 0.5.41(react@19.2.6) + optionalDependencies: + react: 19.2.6 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -18122,6 +18320,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retext-latin@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -18563,6 +18766,8 @@ snapshots: std-env@4.1.0: {} + stdin-discarder@0.3.2: {} + stream-buffers@2.2.0: {} strict-event-emitter@0.5.1: {} @@ -19377,6 +19582,8 @@ snapshots: yoctocolors-cjs@2.1.3: {} + yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} zod-to-json-schema@3.25.2(zod@4.4.3): diff --git a/vite.config.ts b/vite.config.ts index 1ce94c68754..0658a5fa950 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,13 @@ import "vite-plus/test/config"; import { defineConfig } from "vite-plus"; +import { fileURLToPath } from "node:url"; export default defineConfig({ + resolve: { + alias: { + "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), + }, + }, test: { environment: "node", exclude: [ @@ -90,6 +96,18 @@ export default defineConfig({ "typescript/require-array-sort-compare": "off", "typescript/restrict-template-expressions": "off", "typescript/unbound-method": "off", + "eslint/no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@t3tools/client-runtime", + message: + "Import from an explicit @t3tools/client-runtime/* subpath. The package has no root export.", + }, + ], + }, + ], "t3code/no-inline-schema-compile": "warn", "t3code/no-manual-effect-runtime-in-tests": "error", },