diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index fc29dd94ab..1be60f28bb 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -30,6 +30,7 @@
"devDependencies": {
"@effect/vitest": "catalog:",
"@types/node": "catalog:",
+ "acorn": "8.16.0",
"cross-env": "^10.1.0",
"electron-builder": "26.15.6",
"tailwindcss": "^4.0.0",
diff --git a/apps/desktop/scripts/verify-preload-bundle.mjs b/apps/desktop/scripts/verify-preload-bundle.mjs
new file mode 100644
index 0000000000..34d39fb8e3
--- /dev/null
+++ b/apps/desktop/scripts/verify-preload-bundle.mjs
@@ -0,0 +1,147 @@
+import * as NodeEvents from "node:events";
+import * as NodeFS from "node:fs";
+import * as NodeFSP from "node:fs/promises";
+import * as NodeTimers from "node:timers";
+import * as NodeURL from "node:url";
+import * as NodeVM from "node:vm";
+import { parse } from "acorn";
+
+const expectedDesktopBridgeApis = [
+ "getClientPlatform",
+ "getLocalEnvironmentBootstraps",
+ "pickFolder",
+];
+const clerkPasskeysGlobal = "__clerk_internal_electron_passkeys";
+const preloadExecutionTimeoutMs = 1_000;
+const desktopPackage = JSON.parse(
+ NodeFS.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
+);
+const electronVersion = desktopPackage.dependencies.electron;
+
+const isSyntaxNode = (value) =>
+ typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
+
+const inspectBundle = (source) => {
+ const runtimeImports = [];
+ const visit = (node) => {
+ if (node.type === "ImportExpression") {
+ throw new Error("Desktop preload bundle contains a dynamic import() call");
+ }
+
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
+ if (node.callee.name === "require") {
+ const [argument] = node.arguments;
+ if (node.arguments.length !== 1 || argument?.type !== "Literal") {
+ throw new Error("Desktop preload bundle contains a dynamic require() call");
+ }
+ if (typeof argument.value !== "string") {
+ throw new Error("Desktop preload bundle contains a dynamic require() call");
+ }
+ runtimeImports.push(argument.value);
+ }
+ }
+
+ for (const child of Object.values(node)) {
+ if (Array.isArray(child)) {
+ for (const item of child) {
+ if (isSyntaxNode(item)) visit(item);
+ }
+ } else if (isSyntaxNode(child)) {
+ visit(child);
+ }
+ }
+ };
+
+ visit(parse(source, { ecmaVersion: "latest", sourceType: "script" }));
+ return runtimeImports;
+};
+
+const createSandboxModules = (exposedGlobals) => {
+ const ipcRenderer = {
+ invoke: () => Promise.resolve(undefined),
+ on: () => undefined,
+ removeListener: () => undefined,
+ sendSync: () => undefined,
+ };
+ const electron = {
+ contextBridge: {
+ exposeInMainWorld: (name, api) => exposedGlobals.set(name, api),
+ },
+ ipcRenderer,
+ };
+
+ return new Map([
+ ["electron", electron],
+ ["electron/common", electron],
+ ["electron/renderer", electron],
+ ["events", NodeEvents.default],
+ ["node:events", NodeEvents.default],
+ ["timers", NodeTimers.default],
+ ["node:timers", NodeTimers.default],
+ ["url", NodeURL.default],
+ ["node:url", NodeURL.default],
+ ]);
+};
+
+const executeBundle = (source, sandboxModules) => {
+ const sandboxProcess = {
+ contextIsolated: true,
+ // oxlint-disable-next-line t3code/no-global-process-runtime -- This standalone CI verifier supplies the preload's host platform without loading Effect.
+ platform: process.platform,
+ versions: { electron: electronVersion },
+ };
+ const requireSandboxModule = (moduleName) => {
+ if (!sandboxModules.has(moduleName)) {
+ throw new Error(
+ `Unsupported sandbox module requested during preload execution: ${moduleName}`,
+ );
+ }
+ return sandboxModules.get(moduleName);
+ };
+
+ NodeVM.runInNewContext(
+ source,
+ {
+ process: sandboxProcess,
+ require: requireSandboxModule,
+ },
+ {
+ filename: "desktop-preload.cjs",
+ timeout: preloadExecutionTimeoutMs,
+ },
+ );
+};
+
+export const verifyPreloadBundle = (source) => {
+ const runtimeImports = inspectBundle(source);
+ const exposedGlobals = new Map();
+ const sandboxModules = createSandboxModules(exposedGlobals);
+ const unsupportedImports = [...new Set(runtimeImports)]
+ .filter((moduleName) => !sandboxModules.has(moduleName))
+ .toSorted();
+
+ if (unsupportedImports.length > 0) {
+ throw new Error(
+ `Desktop preload bundle contains unsupported sandbox imports: ${unsupportedImports.join(", ")}`,
+ );
+ }
+
+ executeBundle(source, sandboxModules);
+
+ const desktopBridge = exposedGlobals.get("desktopBridge");
+ const missingApis = expectedDesktopBridgeApis.filter(
+ (api) => typeof desktopBridge?.[api] !== "function",
+ );
+ if (!exposedGlobals.has("desktopBridge")) missingApis.unshift("desktopBridge exposure");
+ if (!exposedGlobals.has(clerkPasskeysGlobal)) missingApis.push(`${clerkPasskeysGlobal} exposure`);
+
+ if (missingApis.length > 0) {
+ throw new Error(`Desktop preload bundle is missing executable APIs: ${missingApis.join(", ")}`);
+ }
+};
+
+if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) {
+ const preloadUrl = new URL("../dist-electron/preload.cjs", import.meta.url);
+ const source = await NodeFSP.readFile(preloadUrl, "utf8");
+ verifyPreloadBundle(source);
+}
diff --git a/apps/desktop/scripts/verify-preload-bundle.test.mjs b/apps/desktop/scripts/verify-preload-bundle.test.mjs
new file mode 100644
index 0000000000..a80a6d0964
--- /dev/null
+++ b/apps/desktop/scripts/verify-preload-bundle.test.mjs
@@ -0,0 +1,104 @@
+import { assert, describe, it } from "vite-plus/test";
+
+import { verifyPreloadBundle } from "./verify-preload-bundle.mjs";
+
+const validPreload = `
+ const electron = require("electron");
+ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
+ electron.contextBridge.exposeInMainWorld("__clerk_internal_electron_passkeys", {});
+ electron.contextBridge.exposeInMainWorld("desktopBridge", {
+ getClientPlatform: () => process.platform,
+ getLocalEnvironmentBootstraps: () => [],
+ pickFolder: (options) => electron.ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
+ });
+`;
+
+describe("desktop preload bundle verifier", () => {
+ it("rejects required API names that only appear in strings", () => {
+ assert.throws(
+ () =>
+ verifyPreloadBundle(`
+ "desktopBridge getClientPlatform getLocalEnvironmentBootstraps pickFolder";
+ "__clerk_internal_electron_passkeys";
+ require("electron");
+ `),
+ /missing executable APIs/,
+ );
+ });
+
+ it("rejects a required API whose exposed value is not callable", () => {
+ assert.throws(
+ () =>
+ verifyPreloadBundle(
+ validPreload.replace(
+ "getClientPlatform: () => process.platform,",
+ "getClientPlatform: undefined,",
+ ),
+ ),
+ /missing executable APIs: getClientPlatform/,
+ );
+ });
+
+ it("accepts a required API exposed through a function alias", () => {
+ assert.doesNotThrow(() =>
+ verifyPreloadBundle(`
+ const readClientPlatform = () => process.platform;
+ ${validPreload.replace(
+ "getClientPlatform: () => process.platform,",
+ "getClientPlatform: readClientPlatform,",
+ )}
+ `),
+ );
+ });
+
+ it("rejects dynamic imports with comments before the opening parenthesis", () => {
+ assert.throws(
+ () =>
+ verifyPreloadBundle(`${validPreload}\nimport /* @vite-ignore */("unsupported-module");`),
+ /dynamic import\(\)/,
+ );
+ });
+
+ it("ignores import-like text in strings", () => {
+ assert.doesNotThrow(() =>
+ verifyPreloadBundle(`${validPreload}\nconst message = 'import /* comment */("module")';`),
+ );
+ });
+
+ it("rejects unsupported require calls with comments before the opening parenthesis", () => {
+ assert.throws(
+ () => verifyPreloadBundle(`${validPreload}\nrequire /* @__PURE__ */ ("node:fs");`),
+ /unsupported sandbox imports: node:fs/,
+ );
+ });
+
+ it("rejects unsupported optional require calls", () => {
+ assert.throws(
+ () => verifyPreloadBundle(`${validPreload}\nrequire?.("node:fs");`),
+ /unsupported sandbox imports: node:fs/,
+ );
+ });
+
+ it("accepts Electron sandbox module aliases", () => {
+ assert.doesNotThrow(() =>
+ verifyPreloadBundle(`
+ ${validPreload}
+ require("electron/common");
+ require("electron/renderer");
+ require("node:events");
+ require("node:timers");
+ require("node:url");
+ `),
+ );
+ });
+
+ it("ignores require-like text in strings and comments", () => {
+ assert.doesNotThrow(() =>
+ verifyPreloadBundle(`
+ ${validPreload}
+ const message = 'require("node:fs")';
+ // require("node:path")
+ `),
+ );
+ });
+});
diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
index be5a76e258..36529b1215 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts
@@ -58,14 +58,16 @@ function makeEnvironmentLayer(
readonly devServerUrl?: string;
readonly platform?: NodeJS.Platform;
readonly resourcesPath?: string;
+ readonly appVersion?: string;
+ readonly processArch?: NodeJS.Architecture;
},
) {
return DesktopEnvironment.layer({
dirname: options?.dirname ?? "/repo/apps/desktop/src",
homeDirectory: baseDir,
platform: options?.platform ?? "darwin",
- processArch: "x64",
- appVersion: "1.2.3",
+ processArch: options?.processArch ?? "x64",
+ appVersion: options?.appVersion ?? "1.2.3",
appPath: options?.appPath ?? "/repo",
isPackaged: options?.isPackaged ?? true,
resourcesPath: options?.resourcesPath ?? "/missing/resources",
@@ -123,7 +125,107 @@ const withHarness = (
);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
+interface PackagedWslHarnessContext {
+ readonly baseDir: string;
+ readonly archivePath: string;
+ readonly hashPath: string;
+ readonly archiveHash: string;
+ readonly mountedAppRoot: string;
+ readonly mountedEntryPath: string;
+}
+
+const withPackagedWslHarness = (
+ input: {
+ readonly archiveHash: string;
+ readonly wsl: (
+ context: PackagedWslHarnessContext,
+ ) => DesktopWslEnvironment.DesktopWslEnvironmentTestStub;
+ readonly forbidFallback?: string;
+ readonly cleanupLegacy?: Effect.Effect;
+ readonly forbidCleanup?: string;
+ },
+ effect: (
+ context: PackagedWslHarnessContext,
+ ) => Effect.Effect<
+ A,
+ E,
+ R | FileSystem.FileSystem | Path.Path | DesktopBackendConfiguration.DesktopBackendConfiguration
+ >,
+) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "t3-desktop-backend-config-test-",
+ });
+ const archivePath = path.join(baseDir, "wsl-runtime.tar.gz");
+ const hashPath = `${archivePath}.sha256`;
+ const mountedAppRoot = "/mnt/c/app.asar.unpacked";
+ const mountedEntryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs");
+ yield* fileSystem.makeDirectory(path.dirname(mountedEntryPath), { recursive: true });
+ yield* fileSystem.writeFileString(mountedEntryPath, "");
+ yield* fileSystem.writeFileString(archivePath, "archive");
+ yield* fileSystem.writeFileString(hashPath, `${input.archiveHash}\n`);
+
+ const context = {
+ baseDir,
+ archivePath,
+ hashPath,
+ archiveHash: input.archiveHash,
+ mountedAppRoot,
+ mountedEntryPath,
+ } satisfies PackagedWslHarnessContext;
+ const serverTreeLayer = input.forbidFallback
+ ? Layer.succeed(
+ DesktopWslServerTree.DesktopWslServerTree,
+ DesktopWslServerTree.DesktopWslServerTree.of({
+ ensure: Effect.die(input.forbidFallback),
+ cleanupLegacy: input.forbidCleanup
+ ? Effect.die(input.forbidCleanup)
+ : (input.cleanupLegacy ?? Effect.void),
+ }),
+ )
+ : DesktopWslServerTree.layerTest({
+ result: { ok: true, root: path.join(baseDir, "app.asar.unpacked") },
+ cleanupLegacy: input.cleanupLegacy ?? Effect.void,
+ });
+
+ return yield* effect(context).pipe(
+ Effect.provide(
+ DesktopBackendConfiguration.layer.pipe(
+ Layer.provideMerge(serverExposureLayer),
+ Layer.provideMerge(DesktopAppSettings.layerTest()),
+ Layer.provideMerge(serverTreeLayer),
+ Layer.provideMerge(
+ DesktopWslEnvironment.layerTest({
+ isAvailable: true,
+ distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
+ windowsToWslPath: () => Option.some(mountedAppRoot),
+ getDistroIp: () => Option.some("172.27.0.99"),
+ ...input.wsl(context),
+ }),
+ ),
+ Layer.provideMerge(
+ makeEnvironmentLayer(baseDir, {
+ appPath: baseDir,
+ platform: "win32",
+ resourcesPath: baseDir,
+ }),
+ ),
+ ),
+ ),
+ );
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer));
+
describe("DesktopBackendConfiguration", () => {
+ it("accepts only normalized SHA-256 archive identities", () => {
+ assert.equal(
+ DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`),
+ "a".repeat(64),
+ );
+ assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123"));
+ });
+
it.effect("resolvePrimary produces a stable scoped bootstrap token", () =>
withHarness(
Effect.gen(function* () {
@@ -158,10 +260,11 @@ describe("DesktopBackendConfiguration", () => {
it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});
- const resourcesPath = `${baseDir}/resources`;
+ const resourcesPath = path.join(baseDir, "resources");
const result = yield* Effect.gen(function* () {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
@@ -179,6 +282,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopWslServerTree.DesktopWslServerTree,
DesktopWslServerTree.DesktopWslServerTree.of({
ensure: Effect.die("Windows primary must not extract the WSL server tree"),
+ cleanupLegacy: Effect.die("Windows primary must not clean the WSL server tree"),
}),
),
),
@@ -245,7 +349,7 @@ describe("DesktopBackendConfiguration", () => {
],
windowsToWslPath: (distro) => {
observedDistros.push(distro);
- return Option.some("/repo/apps/server/dist/bin.mjs");
+ return Option.some("/repo");
},
ensureNodePty: (distro) => {
observedDistros.push(distro);
@@ -275,6 +379,275 @@ describe("DesktopBackendConfiguration", () => {
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
+ it.effect("resolveWsl launches a packaged backend from the WSL-local runtime cache", () => {
+ const observedArchives: Array<{
+ windowsArchivePath: string;
+ runtimeId: string;
+ sha256: string;
+ }> = [];
+ const observedNodePtyRoots: string[] = [];
+ let legacyCleanupCount = 0;
+ const linuxAppRoot = "/home/test/.t3/wsl-runtime/1.2.3-x64";
+
+ return withPackagedWslHarness(
+ {
+ archiveHash: "a".repeat(64),
+ forbidFallback: "A valid WSL archive must not extract the Windows fallback",
+ cleanupLegacy: Effect.sync(() => {
+ legacyCleanupCount += 1;
+ }),
+ wsl: () => ({
+ prepareRuntime: (_distro, archive) => {
+ observedArchives.push({
+ windowsArchivePath: archive.windowsPath,
+ runtimeId: archive.runtimeId,
+ sha256: archive.sha256,
+ });
+ return { ok: true, linuxAppRoot };
+ },
+ ensureNodePty: (_distro, root) => {
+ observedNodePtyRoots.push(root);
+ return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
+ },
+ }),
+ },
+ ({ archiveHash, archivePath, baseDir }) =>
+ Effect.gen(function* () {
+ const path = yield* Path.Path;
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+
+ assert.deepEqual(observedArchives, [
+ {
+ windowsArchivePath: archivePath,
+ runtimeId: `sha256-${archiveHash}`,
+ sha256: archiveHash,
+ },
+ ]);
+ assert.deepEqual(observedNodePtyRoots, [linuxAppRoot]);
+ assert.equal(
+ config.entryPath,
+ path.join(baseDir, "server.asar/apps/server/dist/bin.mjs"),
+ );
+ assert.include(config.args, `${linuxAppRoot}/apps/server/dist/bin.mjs`);
+ assert.equal(config.wslRuntimeId, `sha256-${archiveHash}`);
+ assert.equal(legacyCleanupCount, 1);
+ assert.isTrue(Option.isNone(config.preflightFailure));
+ }),
+ );
+ });
+
+ it.effect("resolveWsl changes the cache id when the packaged archive changes", () => {
+ const firstHash = "a".repeat(64);
+ const secondHash = "b".repeat(64);
+ const observedRuntimeIds: string[] = [];
+ return withPackagedWslHarness(
+ {
+ archiveHash: firstHash,
+ wsl: () => ({
+ prepareRuntime: (_distro, archive) => {
+ observedRuntimeIds.push(archive.runtimeId);
+ return { ok: true, linuxAppRoot: `/runtime/${archive.runtimeId}` };
+ },
+ ensureNodePty: () => ({
+ ok: true,
+ nodePath: "/usr/bin/node",
+ resolvedPath: "/usr/bin:/bin",
+ }),
+ }),
+ },
+ ({ hashPath, mountedAppRoot }) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const first = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+ yield* fileSystem.writeFileString(hashPath, secondHash);
+ const second = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+ yield* fileSystem.writeFileString(hashPath, "not-a-sha256");
+ const invalidIdentity = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+
+ assert.deepEqual(observedRuntimeIds, [`sha256-${firstHash}`, `sha256-${secondHash}`]);
+ assert.equal(first.wslRuntimeId, observedRuntimeIds[0]);
+ assert.equal(second.wslRuntimeId, observedRuntimeIds[1]);
+ assert.isUndefined(invalidIdentity.wslRuntimeId);
+ assert.include(invalidIdentity.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
+ }),
+ );
+ });
+
+ it.effect("resolveWsl falls back to the mounted runtime when archive staging fails", () => {
+ const observedNodePtyRoots: string[] = [];
+ return withPackagedWslHarness(
+ {
+ archiveHash: "b".repeat(64),
+ wsl: () => ({
+ prepareRuntime: () => ({ ok: false, reason: "archive is corrupt" }),
+ ensureNodePty: (_distro, root) => {
+ observedNodePtyRoots.push(root);
+ return { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
+ },
+ }),
+ },
+ ({ mountedAppRoot, mountedEntryPath }) =>
+ Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+
+ assert.deepEqual(observedNodePtyRoots, [mountedAppRoot]);
+ assert.equal(config.entryPath, mountedEntryPath);
+ assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
+ assert.isUndefined(config.wslRuntimeId);
+ assert.isTrue(Option.isNone(config.preflightFailure));
+ }),
+ );
+ });
+
+ it.effect("resolveWsl retires a staged runtime that cannot load node-pty", () => {
+ const archiveHash = "c".repeat(64);
+ const stagedAppRoot = `/home/test/.t3/wsl-runtime/sha256-${archiveHash}`;
+ const observedNodePtyRoots: string[] = [];
+ const invalidatedRuntimeIds: string[] = [];
+ return withPackagedWslHarness(
+ {
+ archiveHash,
+ wsl: () => ({
+ prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }),
+ invalidateRuntime: (_distro, runtimeId) =>
+ Effect.sync(() => {
+ invalidatedRuntimeIds.push(runtimeId);
+ }),
+ ensureNodePty: (_distro, root) => {
+ observedNodePtyRoots.push(root);
+ return root === stagedAppRoot
+ ? { ok: false, reason: "pty.node could not be loaded", fatal: true }
+ : { ok: true, nodePath: "/usr/bin/node", resolvedPath: "/usr/bin:/bin" };
+ },
+ }),
+ },
+ ({ mountedAppRoot, mountedEntryPath }) =>
+ Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+
+ assert.deepEqual(observedNodePtyRoots, [stagedAppRoot, mountedAppRoot]);
+ assert.include(config.args, `${mountedAppRoot}/apps/server/dist/bin.mjs`);
+ assert.equal(config.entryPath, mountedEntryPath);
+ assert.isUndefined(config.wslRuntimeId);
+ assert.isTrue(Option.isNone(config.preflightFailure));
+ assert.deepEqual(invalidatedRuntimeIds, [`sha256-${archiveHash}`]);
+ }),
+ );
+ });
+
+ it.effect("resolveWsl keeps the staged runtime when the mounted tree fails too", () => {
+ const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache";
+ const invalidatedRuntimeIds: string[] = [];
+ return withPackagedWslHarness(
+ {
+ archiveHash: "d".repeat(64),
+ wsl: () => ({
+ prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }),
+ invalidateRuntime: (_distro, runtimeId) =>
+ Effect.sync(() => {
+ invalidatedRuntimeIds.push(runtimeId);
+ }),
+ ensureNodePty: (_distro, root) => ({
+ ok: false,
+ reason:
+ root === stagedAppRoot
+ ? "unsupported CPU architecture or incompatible system libraries"
+ : "mounted tree is broken in some other way",
+ fatal: true,
+ }),
+ }),
+ },
+ () =>
+ Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+ const failure = Option.getOrThrow(config.preflightFailure);
+
+ assert.isTrue(failure.fatal);
+ assert.include(failure.reason, "unsupported CPU architecture");
+ assert.deepEqual(invalidatedRuntimeIds, []);
+ }),
+ );
+ });
+
+ it.effect("resolveWsl keeps WSL retryable when the mounted fallback fails transiently", () => {
+ const stagedAppRoot = "/home/test/.t3/wsl-runtime/cache";
+ const invalidatedRuntimeIds: string[] = [];
+ return withPackagedWslHarness(
+ {
+ archiveHash: "f".repeat(64),
+ wsl: () => ({
+ prepareRuntime: () => ({ ok: true, linuxAppRoot: stagedAppRoot }),
+ invalidateRuntime: (_distro, runtimeId) =>
+ Effect.sync(() => {
+ invalidatedRuntimeIds.push(runtimeId);
+ }),
+ ensureNodePty: (_distro, root) =>
+ root === stagedAppRoot
+ ? { ok: false, reason: "pty.node could not be loaded", fatal: true }
+ : {
+ ok: false,
+ reason: "WSL backend preflight timed out while probing for Node.js.",
+ fatal: false,
+ },
+ }),
+ },
+ () =>
+ Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+ const failure = Option.getOrThrow(config.preflightFailure);
+
+ assert.isFalse(failure.fatal);
+ assert.equal(failure.retryLimit, 12);
+ assert.include(failure.reason, "timed out");
+ assert.deepEqual(invalidatedRuntimeIds, []);
+ }),
+ );
+ });
+
+ it.effect("resolveWsl retries the staged runtime after a transient probe failure", () => {
+ const invalidatedRuntimeIds: string[] = [];
+ return withPackagedWslHarness(
+ {
+ archiveHash: "e".repeat(64),
+ forbidFallback: "A transient probe failure must not extract the fallback",
+ forbidCleanup: "A transient probe failure must not clean the fallback tree",
+ wsl: () => ({
+ prepareRuntime: () => ({
+ ok: true,
+ linuxAppRoot: "/home/test/.t3/wsl-runtime/cache",
+ }),
+ invalidateRuntime: (_distro, runtimeId) =>
+ Effect.sync(() => {
+ invalidatedRuntimeIds.push(runtimeId);
+ }),
+ ensureNodePty: () => ({
+ ok: false,
+ reason: "WSL backend preflight timed out while probing for Node.js.",
+ fatal: false,
+ retryLimit: 12,
+ }),
+ }),
+ },
+ () =>
+ Effect.gen(function* () {
+ const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
+ const config = yield* configuration.resolveWsl({ port: 5000, distro: "Ubuntu" });
+ const failure = Option.getOrThrow(config.preflightFailure);
+
+ assert.isFalse(failure.fatal);
+ assert.equal(failure.retryLimit, 12);
+ assert.include(failure.reason, "timed out");
+ assert.deepEqual(invalidatedRuntimeIds, []);
+ }),
+ );
+ });
+
it.effect(
"resolveWsl preserves inherited PATH with quote-sensitive values as separate args",
() =>
@@ -289,7 +662,8 @@ describe("DesktopBackendConfiguration", () => {
yield* fileSystem.writeFileString(entryPath, "");
const nodePath = "/home/test user's/.nvm/versions/node/v22.0.0/bin/node";
- const linuxEntryPath = "/tmp/t3 code's launch/entry file.mjs";
+ const linuxAppRoot = "/tmp/t3 code's launch";
+ const linuxEntryPath = `${linuxAppRoot}/apps/server/dist/bin.mjs`;
const resolvedPath = "/home/test user/bin:/opt/test's tools/bin:/usr/bin:/bin";
const devServerUrl = "http://127.0.0.1:5733/dev%20assets/?label=hello%20world";
const config = yield* Effect.gen(function* () {
@@ -305,7 +679,7 @@ describe("DesktopBackendConfiguration", () => {
DesktopWslEnvironment.layerTest({
isAvailable: true,
distros: [{ name: "Ubuntu", isDefault: true, version: 2 }],
- windowsToWslPath: () => Option.some(linuxEntryPath),
+ windowsToWslPath: () => Option.some(linuxAppRoot),
ensureNodePty: () => ({ ok: true, nodePath, resolvedPath }),
getDistroIp: () => Option.some("172.27.0.99"),
}),
@@ -821,13 +1195,14 @@ describe("DesktopBackendConfiguration", () => {
it.effect("prefers the external packaged resource monitor over the copy inside the asar", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-desktop-backend-config-test-",
});
- const resourcesPath = `${baseDir}/resources`;
+ const resourcesPath = path.join(baseDir, "resources");
const dirname = `${resourcesPath}/app.asar/apps/desktop/dist-electron`;
const embeddedMonitorPath = `${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor/t3-resource-monitor`;
- const monitorPath = `${resourcesPath}/resource-monitor/t3-resource-monitor`;
+ const monitorPath = path.join(resourcesPath, "resource-monitor/t3-resource-monitor");
yield* fileSystem.makeDirectory(
`${resourcesPath}/app.asar/apps/desktop/prod-resources/resource-monitor`,
{ recursive: true },
diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
index bcce731a59..4c43070b5f 100644
--- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts
+++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts
@@ -213,6 +213,7 @@ interface SharedBootstrapInput {
interface WslPreflightSuccess {
readonly _tag: "Ready";
readonly runningDistro: string;
+ readonly windowsEntryPath: string;
readonly linuxEntryPath: string;
// Absolute path to the node binary the preflight validated after the shared
// remote resolver repaired PATH. The launch must use this exact path so it
@@ -222,6 +223,8 @@ interface WslPreflightSuccess {
// PATH captured from the same login shell after the shared resolver loaded
// version managers. The launch forwards this value directly without a shell.
readonly resolvedPath: string;
+ // Identifies the distro-local runtime cache selected from the packaged archive.
+ readonly runtimeId?: string;
}
interface WslPreflightFailure {
@@ -236,18 +239,35 @@ interface WslPreflightFailure {
}
const WSL_TRANSIENT_PREFLIGHT_RETRY_LIMIT = 12;
+const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz";
+const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`;
+const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i;
+
+export const parseWslRuntimeArchiveHash = (value: string): string | null => {
+ const trimmed = value.trim();
+ return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null;
+};
+
+type FailedNodePtyResult = Extract<
+ DesktopWslEnvironment.EnsureWslNodePtyResult,
+ { readonly ok: false }
+>;
const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(function* (input: {
readonly distro: string | null;
- readonly windowsEntryPath: string;
- readonly windowsRepoRoot: string;
+ readonly runtimeArchive: DesktopWslEnvironment.WslRuntimeArchive | null;
readonly allowBuild: boolean;
}): Effect.fn.Return<
WslPreflightSuccess | WslPreflightFailure,
never,
- DesktopWslEnvironment.DesktopWslEnvironment | FileSystem.FileSystem
+ | DesktopEnvironment.DesktopEnvironment
+ | DesktopWslEnvironment.DesktopWslEnvironment
+ | DesktopWslServerTree.DesktopWslServerTree
+ | FileSystem.FileSystem
> {
+ const environment = yield* DesktopEnvironment.DesktopEnvironment;
const wslEnv = yield* DesktopWslEnvironment.DesktopWslEnvironment;
+ const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
const fileSystem = yield* FileSystem.FileSystem;
const wslAvailable = yield* wslEnv.isAvailable;
@@ -289,43 +309,127 @@ const runWslPreflight = Effect.fn("desktop.backendConfiguration.wslPreflight")(f
} as const;
}
- const entryExists = yield* fileSystem
- .exists(input.windowsEntryPath)
- .pipe(Effect.orElseSucceed(() => false));
- if (!entryExists) {
- return {
+ const nodePtyOptions = {
+ allowBuild: input.allowBuild,
+ nodeEngineRange: serverPackageJson.engines.node,
+ };
+ const failedNodePty = (result: FailedNodePtyResult) =>
+ ({
_tag: "Failed",
- reason: `missing server entry at ${input.windowsEntryPath}`,
- fatal: true,
- } as const;
+ reason: `WSL node-pty unavailable: ${result.reason}`,
+ fatal: result.fatal,
+ ...(result.retryLimit === undefined ? {} : { retryLimit: result.retryLimit }),
+ }) as const;
+
+ // The mounted server tree is the fallback runtime: the Windows-side copy the
+ // distro reads over /mnt. Slower to launch from, but always installed.
+ const resolveMountedAppRoot = Effect.gen(function* () {
+ const serverTree = yield* wslServerTree.ensure;
+ if (!serverTree.ok) {
+ return { ok: false, reason: serverTree.reason, fatal: serverTree.fatal } as const;
+ }
+ const windowsEntryPath = environment.path.join(serverTree.root, "apps/server/dist/bin.mjs");
+ const entryExists = yield* fileSystem
+ .exists(windowsEntryPath)
+ .pipe(Effect.orElseSucceed(() => false));
+ if (!entryExists) {
+ return {
+ ok: false,
+ reason: `missing server entry at ${windowsEntryPath}`,
+ fatal: true,
+ } as const;
+ }
+ const mountedAppRoot = yield* wslEnv.windowsToWslPath(runningDistro, serverTree.root);
+ return Option.isNone(mountedAppRoot)
+ ? ({
+ ok: false,
+ reason: `wslpath conversion failed for ${serverTree.root}`,
+ fatal: false,
+ } as const)
+ : ({ ok: true, windowsEntryPath, linuxAppRoot: mountedAppRoot.value } as const);
+ });
+
+ // Set once a staged runtime has been ruled out by the probe, and carried
+ // through the mounted attempt: if the mounted tree works the cache is the
+ // broken part and gets invalidated, and if the mounted tree returns its own
+ // fatal verdict the cached reason is the more actionable one to report.
+ // A transient mounted failure is neither — it rules nothing out, so it stays
+ // retryable and the staged verdict waits for an attempt that can answer.
+ let stagedFailure:
+ | { readonly runtimeId: string; readonly nodePty: FailedNodePtyResult }
+ | undefined;
+
+ if (input.runtimeArchive !== null) {
+ const runtime = yield* wslEnv.prepareRuntime(runningDistro, input.runtimeArchive);
+ if (runtime.ok) {
+ const stagedNodePty = yield* wslEnv.ensureNodePty(
+ runningDistro,
+ runtime.linuxAppRoot,
+ nodePtyOptions,
+ );
+ if (stagedNodePty.ok) {
+ yield* wslServerTree.cleanupLegacy;
+ return {
+ _tag: "Ready",
+ runningDistro,
+ windowsEntryPath: environment.backendEntryPath,
+ linuxEntryPath: `${runtime.linuxAppRoot}/apps/server/dist/bin.mjs`,
+ nodePath: stagedNodePty.nodePath,
+ resolvedPath: stagedNodePty.resolvedPath,
+ runtimeId: input.runtimeArchive.runtimeId,
+ } as const;
+ }
+ // A transport failure says nothing about the staged tree, so it is
+ // retried against the same cache rather than spending a second probe on
+ // the mounted tree and risking a needless reinstall.
+ if (!stagedNodePty.fatal) return failedNodePty(stagedNodePty);
+ yield* Effect.logWarning(
+ "The staged WSL runtime could not load node-pty; retrying from the mounted server tree.",
+ { reason: stagedNodePty.reason },
+ );
+ stagedFailure = { runtimeId: input.runtimeArchive.runtimeId, nodePty: stagedNodePty };
+ } else {
+ yield* Effect.logWarning(
+ "Could not stage the WSL runtime; launching from the mounted server tree instead.",
+ { reason: runtime.reason },
+ );
+ }
}
- const linuxEntry = yield* wslEnv.windowsToWslPath(runningDistro, input.windowsEntryPath);
- if (Option.isNone(linuxEntry)) {
- return {
- _tag: "Failed",
- reason: `wslpath conversion failed for ${input.windowsEntryPath}`,
- fatal: false,
- } as const;
+ const mounted = yield* resolveMountedAppRoot;
+ if (!mounted.ok) {
+ return stagedFailure && mounted.fatal
+ ? failedNodePty(stagedFailure.nodePty)
+ : ({ _tag: "Failed", reason: mounted.reason, fatal: mounted.fatal } as const);
}
- const nodePtyResult = yield* wslEnv.ensureNodePty(runningDistro, input.windowsRepoRoot, {
- allowBuild: input.allowBuild,
- nodeEngineRange: serverPackageJson.engines.node,
- });
+ const nodePtyResult = yield* wslEnv.ensureNodePty(
+ runningDistro,
+ mounted.linuxAppRoot,
+ nodePtyOptions,
+ );
if (!nodePtyResult.ok) {
- return {
- _tag: "Failed",
- reason: `WSL node-pty unavailable: ${nodePtyResult.reason}`,
- fatal: nodePtyResult.fatal,
- ...(nodePtyResult.retryLimit === undefined ? {} : { retryLimit: nodePtyResult.retryLimit }),
- } as const;
+ // Substituting the staged verdict for a transient mounted failure would
+ // turn a retryable failure into a fatal one, ending the WSL attempt (and,
+ // in wsl-only mode, persisting Windows) before the slow /mnt path had a
+ // chance to answer and clear the bad cache.
+ return failedNodePty(
+ stagedFailure && nodePtyResult.fatal ? stagedFailure.nodePty : nodePtyResult,
+ );
+ }
+
+ // The mounted tree runs what the cache could not, so the cache is the broken
+ // copy: revoke its ready marker so the next launch reinstalls it instead of
+ // reusing a tree that has already been proven unloadable.
+ if (stagedFailure) {
+ yield* wslEnv.invalidateRuntime(runningDistro, stagedFailure.runtimeId);
}
return {
_tag: "Ready",
runningDistro,
- linuxEntryPath: linuxEntry.value,
+ windowsEntryPath: mounted.windowsEntryPath,
+ linuxEntryPath: `${mounted.linuxAppRoot}/apps/server/dist/bin.mjs`,
nodePath: nodePtyResult.nodePath,
resolvedPath: nodePtyResult.resolvedPath,
} as const;
@@ -430,7 +534,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
> {
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
- const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree;
+ const fileSystem = yield* FileSystem.FileSystem;
// Bind to 0.0.0.0 inside WSL so the backend is reachable both via
// WSL2's automatic localhost forwarding (wslhost: Windows 127.0.0.1
@@ -467,31 +571,54 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
...buildObservabilityFragment(input.observabilitySettings),
};
- // In packaged builds the server tree ships inside resources/server.asar —
- // an archive FILE the Windows primary reads through ELECTRON_RUN_AS_NODE
- // (asar-aware). The WSL backend launches plain `wsl.exe -- node`, which
- // can't read an asar, so materialize (or reuse) the extracted copy of the
- // sidecar before preflighting. In dev the server tree is the real checkout
- // directory and ensure returns it unchanged.
- const serverTree = yield* wslServerTree.ensure;
- const wslAppRoot = serverTree.ok ? serverTree.root : environment.serverRoot;
- const wslEntryPath = environment.path.join(wslAppRoot, "apps/server/dist/bin.mjs");
-
- const preflight = serverTree.ok
- ? yield* runWslPreflight({
- distro: input.distro,
- windowsEntryPath: wslEntryPath,
- windowsRepoRoot: wslAppRoot,
- // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
- // attached to the Windows artifact — see build-desktop-artifact.ts), so the
- // WSL backend never needs a compiler, node-gyp, or network on first launch.
- // Compiling from source is a dev-only convenience: a checkout has no shipped
- // prebuilt, and developers have the toolchain. In packaged builds we instead
- // surface a clear diagnostic if the prebuilt can't load (unsupported
- // arch/distro), rather than silently dropping into a fragile runtime build.
- allowBuild: !environment.isPackaged,
- })
- : ({ _tag: "Failed", reason: serverTree.reason, fatal: serverTree.fatal } as const);
+ // The archive is the primary packaged WSL path: it installs directly into
+ // the distro's ext4 filesystem. The server.asar extraction service is only
+ // consulted lazily if the archive is unavailable or cannot be staged.
+ const archivePath = environment.path.join(environment.resourcesPath, WSL_RUNTIME_ARCHIVE_NAME);
+ const archiveHashPath = environment.path.join(
+ environment.resourcesPath,
+ WSL_RUNTIME_ARCHIVE_HASH_NAME,
+ );
+
+ const hasArchive = environment.isPackaged
+ ? yield* fileSystem.exists(archivePath).pipe(Effect.orElseSucceed(() => false))
+ : false;
+ const archiveHash = hasArchive
+ ? yield* fileSystem.readFileString(archiveHashPath).pipe(
+ Effect.map(parseWslRuntimeArchiveHash),
+ Effect.orElseSucceed(() => null),
+ )
+ : null;
+ if (hasArchive && archiveHash === null) {
+ yield* Effect.logWarning(
+ "Ignoring the WSL runtime archive because its SHA-256 identity is missing or invalid; launching from the mounted server tree instead.",
+ { hashPath: archiveHashPath },
+ );
+ }
+
+ const preflight = yield* runWslPreflight({
+ distro: input.distro,
+ runtimeArchive:
+ archiveHash === null
+ ? null
+ : {
+ windowsPath: archivePath,
+ // The verified archive bytes are the cache identity. Release builds
+ // embed the release version and pnpm install metadata, so the
+ // archive changes on every update even when application logic does
+ // not. Later launches of that update still reuse this directory.
+ runtimeId: `sha256-${archiveHash}`,
+ sha256: archiveHash,
+ },
+ // Packaged builds ship a prebuilt Linux node-pty (built on Linux in CI and
+ // attached to the Windows artifact — see build-desktop-artifact.ts), so the
+ // WSL backend never needs a compiler, node-gyp, or network on first launch.
+ // Compiling from source is a dev-only convenience: a checkout has no shipped
+ // prebuilt, and developers have the toolchain. In packaged builds we instead
+ // surface a clear diagnostic if the prebuilt can't load (unsupported
+ // arch/distro), rather than silently dropping into a fragile runtime build.
+ allowBuild: !environment.isPackaged,
+ });
// Every operation after preflight uses the same concrete distro. In
// default-tracking mode this closes the race where the system default
@@ -537,7 +664,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
const baseConfig = {
executablePath: "wsl.exe",
- entryPath: wslEntryPath,
+ entryPath:
+ preflight._tag === "Ready" ? preflight.windowsEntryPath : environment.backendEntryPath,
cwd: environment.backendCwd,
env: {
...parentEnvWithoutT3Home,
@@ -605,6 +733,7 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl
...devUrlArgs,
],
preflightFailure: Option.none(),
+ ...(preflight.runtimeId === undefined ? {} : { wslRuntimeId: preflight.runtimeId }),
} satisfies DesktopBackendManager.DesktopBackendStartConfig;
});
diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts
index 3efc81ed5b..e6c9a2c17c 100644
--- a/apps/desktop/src/backend/DesktopBackendManager.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts
@@ -25,6 +25,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
+import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
const decodeDesktopBackendBootstrap = Schema.decodeEffect(
Schema.fromJsonString(DesktopBackendBootstrap),
@@ -132,6 +133,7 @@ interface MakeInstanceInput {
readonly desktopTelemetryPublisher?: Partial<
DesktopTelemetryPublisher.DesktopTelemetryPublisher["Service"]
>;
+ readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect;
}
// Helper that constructs a primary backend instance using the factory
@@ -167,6 +169,9 @@ function makeTestInstance(input: MakeInstanceInput) {
removeControlSource: () => Effect.void,
...input.desktopTelemetryPublisher,
}),
+ DesktopWslEnvironment.layerTest(
+ input.pruneRuntimes === undefined ? {} : { pruneRuntimes: input.pruneRuntimes },
+ ),
);
const instance = DesktopBackendManager.makeBackendInstance({
@@ -647,10 +652,13 @@ describe("DesktopBackendManager", () => {
Effect.scoped(
Effect.gen(function* () {
const requestUrls: Array = [];
+ const prunedRuntimes: Array<[string | null, string]> = [];
const statuses = [503, 200];
let readyCount = 0;
const firstRequest = yield* Deferred.make();
- const ready = yield* Deferred.make();
+ const backendReady = yield* Deferred.make();
+ const processExit = yield* Deferred.make();
+ const pruneComplete = yield* Deferred.make();
const exited = yield* Queue.unbounded();
const spawnerLayer = Layer.succeed(
@@ -658,7 +666,9 @@ describe("DesktopBackendManager", () => {
ChildProcessSpawner.make(() =>
Effect.succeed(
makeProcess({
- exitCode: Deferred.await(ready).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
+ exitCode: Deferred.await(processExit).pipe(
+ Effect.as(ChildProcessSpawner.ExitCode(0)),
+ ),
}),
),
),
@@ -666,6 +676,15 @@ describe("DesktopBackendManager", () => {
const instance = yield* makeTestInstance({
spawnerLayer,
+ config: {
+ ...baseConfig,
+ runningDistro: "Ubuntu",
+ wslRuntimeId: "1.2.3-x64",
+ },
+ pruneRuntimes: (distro, runtimeId) =>
+ Effect.sync(() => {
+ prunedRuntimes.push([distro, runtimeId]);
+ }).pipe(Effect.andThen(Deferred.succeed(pruneComplete, void 0)), Effect.asVoid),
httpClientLayer: httpClientLayer((request) =>
Effect.gen(function* () {
const status = statuses.shift();
@@ -677,7 +696,7 @@ describe("DesktopBackendManager", () => {
),
onReady: Effect.sync(() => {
readyCount += 1;
- }).pipe(Effect.andThen(Deferred.succeed(ready, void 0)), Effect.asVoid),
+ }).pipe(Effect.andThen(Deferred.succeed(backendReady, void 0)), Effect.asVoid),
backendOutputLog: {
persistFailure: () => Queue.offer(exited, void 0).pipe(Effect.asVoid),
},
@@ -687,12 +706,17 @@ describe("DesktopBackendManager", () => {
yield* Deferred.await(firstRequest);
assert.equal(readyCount, 0);
+ assert.deepEqual(prunedRuntimes, []);
assert.deepEqual(requestUrls, ["http://127.0.0.1:3773/.well-known/t3/environment"]);
yield* TestClock.adjust(Duration.millis(100));
+ yield* Deferred.await(backendReady);
+ yield* Deferred.await(pruneComplete);
+ yield* Deferred.succeed(processExit, void 0);
yield* Queue.take(exited);
assert.equal(readyCount, 1);
+ assert.deepEqual(prunedRuntimes, [["Ubuntu", "1.2.3-x64"]]);
assert.deepEqual(requestUrls, [
"http://127.0.0.1:3773/.well-known/t3/environment",
"http://127.0.0.1:3773/.well-known/t3/environment",
diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts
index 7a1e4bf502..0ec9bc8a2f 100644
--- a/apps/desktop/src/backend/DesktopBackendManager.ts
+++ b/apps/desktop/src/backend/DesktopBackendManager.ts
@@ -52,6 +52,7 @@ import { waitForHttpReady as waitForHttpReadyShared } from "@t3tools/shared/http
import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
+import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
const INITIAL_RESTART_DELAY = Duration.millis(500);
const MAX_RESTART_DELAY = Duration.seconds(10);
@@ -99,6 +100,10 @@ export interface DesktopBackendStartConfig extends BackendProcessContext {
// Present for a WSL run after the configured/default distro has been
// resolved to the concrete distro passed to wsl.exe.
readonly runningDistro?: string;
+ // Present only when this run launched from a staged WSL-local runtime.
+ // Once HTTP readiness succeeds, the manager uses it to retain this cache
+ // plus the newest previous cache and prune older versions.
+ readonly wslRuntimeId?: string;
}
// A preflight failure records whether it is fatal. Transient failures (WSL
@@ -637,6 +642,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
| HttpClient.HttpClient
| DesktopObservability.DesktopBackendOutputLogFactory
| DesktopTelemetryPublisher.DesktopTelemetryPublisher
+ | DesktopWslEnvironment.DesktopWslEnvironment
| Scope.Scope
> {
const parentScope = yield* Scope.Scope;
@@ -644,6 +650,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
const backendOutputLogFactory = yield* DesktopObservability.DesktopBackendOutputLogFactory;
const backendOutputLog = yield* backendOutputLogFactory.forInstance(spec.id);
const desktopTelemetryPublisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher;
+ const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const state = yield* Ref.make(initialState);
@@ -939,6 +946,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* (
}
yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void;
+ if (
+ config.value.runningDistro !== undefined &&
+ config.value.wslRuntimeId !== undefined
+ ) {
+ yield* wslEnvironment.pruneRuntimes(
+ config.value.runningDistro,
+ config.value.wslRuntimeId,
+ );
+ }
}),
onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")(
function* (error) {
diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts
index 5c0fa02c5c..84e09737b0 100644
--- a/apps/desktop/src/backend/DesktopBackendPool.test.ts
+++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts
@@ -14,6 +14,7 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
+import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
import * as DesktopBackendPool from "./DesktopBackendPool.ts";
import type { DesktopBackendSnapshot, DesktopBackendStartConfig } from "./DesktopBackendManager.ts";
@@ -79,6 +80,7 @@ function makePoolLayer(
resolveWsl: () => Effect.die("unexpected WSL config resolve"),
} satisfies DesktopBackendConfiguration.DesktopBackendConfiguration["Service"]),
DesktopAppSettings.layerTest(),
+ DesktopWslEnvironment.layerTest(),
ElectronDialog.layer,
Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected window create"),
diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts
index 9b85d1bb24..27e24d55c6 100644
--- a/apps/desktop/src/backend/DesktopBackendPool.ts
+++ b/apps/desktop/src/backend/DesktopBackendPool.ts
@@ -99,6 +99,7 @@ import * as DesktopObservability from "../app/DesktopObservability.ts";
import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
+import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
const { logWarning: logBackendPoolWarning } =
@@ -178,7 +179,8 @@ export type BackendInstanceFactoryRequirements =
| ChildProcessSpawner.ChildProcessSpawner
| HttpClient.HttpClient
| DesktopObservability.DesktopBackendOutputLogFactory
- | DesktopTelemetryPublisher.DesktopTelemetryPublisher;
+ | DesktopTelemetryPublisher.DesktopTelemetryPublisher
+ | DesktopWslEnvironment.DesktopWslEnvironment;
interface ActiveRegisteredInstance {
readonly _tag: "Active";
diff --git a/apps/desktop/src/ipc/methods/preview.test.ts b/apps/desktop/src/ipc/methods/preview.test.ts
index 92336cc736..e7770dc629 100644
--- a/apps/desktop/src/ipc/methods/preview.test.ts
+++ b/apps/desktop/src/ipc/methods/preview.test.ts
@@ -1,4 +1,5 @@
import { it as effectIt } from "@effect/vitest";
+import { PreviewAutomationStatus } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
@@ -51,4 +52,46 @@ describe("preview IPC methods", () => {
},
),
);
+
+ effectIt.effect("returns automation status for long runtime tab ids", () =>
+ Effect.gen(function* () {
+ const tabId =
+ `["environment-1","thread:delegated-task:${"a".repeat(120)}",` +
+ `"server-epoch-1","preview-1"]`;
+ const status = {
+ available: false,
+ visible: true,
+ tabId,
+ url: null,
+ title: null,
+ loading: false,
+ };
+ const manager = PreviewManager.PreviewManager.of({
+ automationStatus: () => Effect.succeed(status),
+ } as unknown as PreviewManager.PreviewManager["Service"]);
+
+ expect(tabId.length).toBeGreaterThan(128);
+ expect(
+ yield* PreviewIpc.automationStatus
+ .handler({ tabId })
+ .pipe(Effect.provideService(PreviewManager.PreviewManager, manager)),
+ ).toEqual(status);
+ }),
+ );
+
+ it("keeps the public automation status tab id limit", () => {
+ const encode = Schema.encodeUnknownSync(PreviewAutomationStatus);
+ const tabId = "t".repeat(129);
+
+ expect(() =>
+ encode({
+ available: false,
+ visible: true,
+ tabId,
+ url: null,
+ title: null,
+ loading: false,
+ }),
+ ).toThrow();
+ });
});
diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts
index 9850230a03..5229d36c31 100644
--- a/apps/desktop/src/ipc/methods/preview.ts
+++ b/apps/desktop/src/ipc/methods/preview.ts
@@ -5,6 +5,7 @@ import {
DesktopPreviewAutomationEvaluateInputSchema,
DesktopPreviewAutomationPressInputSchema,
DesktopPreviewAutomationScrollInputSchema,
+ DesktopPreviewAutomationStatusSchema,
DesktopPreviewAutomationTypeInputSchema,
DesktopPreviewAutomationWaitForInputSchema,
DesktopPreviewConfigInputSchema,
@@ -20,7 +21,6 @@ import {
DesktopPreviewWebviewConfigSchema,
PreviewAnnotationSubmissionResultSchema,
PreviewAutomationSnapshot,
- PreviewAutomationStatus,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
@@ -282,7 +282,7 @@ export const copyArtifactToClipboard = DesktopIpc.makeIpcMethod({
export const automationStatus = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL,
payload: DesktopPreviewTabInputSchema,
- result: PreviewAutomationStatus,
+ result: DesktopPreviewAutomationStatusSchema,
handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) {
const manager = yield* PreviewManager.PreviewManager;
return yield* manager.automationStatus(tabId);
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index f58bc35929..1fc84c958d 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -11,6 +11,9 @@ import * as IpcChannels from "./ipc/channels.ts";
exposeClerkBridge({ passkeys: true });
+// oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process.
+const clientPlatform = process.platform;
+
function unwrapEnsureSshEnvironmentResult(result: unknown) {
if (
typeof result === "object" &&
@@ -35,6 +38,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType;
},
+ getClientPlatform: () => clientPlatform,
getSystemLocale: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
return typeof result === "string" ? result : null;
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index 3bf6d63051..9c06d24483 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -58,6 +58,50 @@ describe("isPreviewRefreshShortcut", () => {
});
});
+describe("previewWindowOpenAction", () => {
+ const details = (overrides: {
+ readonly url?: string;
+ readonly disposition?: Electron.HandlerDetails["disposition"];
+ }) => ({
+ url: "https://accounts.google.com/o/oauth2/auth",
+ disposition: "new-window" as Electron.HandlerDetails["disposition"],
+ ...overrides,
+ });
+
+ it("opens a real window for scripted popups so the opener survives", () => {
+ // OAuth SDKs read a null `window.open()` as a blocked popup, and they need
+ // the opener alive to receive the credential back.
+ expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup");
+ expect(
+ PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })),
+ ).toBe("popup");
+ });
+
+ it("keeps target=_blank links in the preview tab", () => {
+ expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe(
+ "navigate",
+ );
+ expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe(
+ "navigate",
+ );
+ });
+
+ it("does not hand a window to schemes that cannot be hardened", () => {
+ // A popup skips the `will-attach-webview` hardening, so it only gets a window
+ // when its preferences can be overridden. Chromium copies the guest's
+ // preferences for `about:blank` and forbids overriding them.
+ for (const url of [
+ "about:blank",
+ "javascript:alert(1)",
+ "file:///etc/passwd",
+ "vscode://vscode-remote/ssh-remote+box/tmp",
+ "not a url",
+ ]) {
+ expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate");
+ }
+ });
+});
+
const {
browserWindowConstructor,
createFromPath,
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 0d90e0175f..0112204503 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -7,6 +7,7 @@
*/
import type {
DesktopPreviewAnnotationTheme,
+ DesktopPreviewAutomationStatus,
DesktopPreviewColorScheme,
DesktopPreviewFavicon,
DesktopPreviewPointerEvent,
@@ -25,7 +26,6 @@ import type {
PreviewAutomationNetworkEntry,
PreviewAutomationScrollInput,
PreviewAutomationSnapshot,
- PreviewAutomationStatus,
PreviewAutomationTypeInput,
PreviewAutomationWaitForInput,
} from "@t3tools/contracts";
@@ -434,6 +434,61 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
{ key: "w", meta: true, shift: false, control: false },
]);
+/**
+ * Protocols a preview page may open in a real popup window.
+ *
+ * `about:blank` stays out: Chromium skips browser-side navigation for it, so the
+ * child copies the guest's `contextIsolation: false` preferences and Electron
+ * gives no way to override them. Those popups keep loading in the preview tab.
+ *
+ * Deliberately not `ElectronShell.parseSafeExternalUrl`: that also admits
+ * `vscode://vscode-remote/...` deep links, which belong in `shell.openExternal`
+ * and not in a window spawned by a third-party page in the preview.
+ */
+const POPUP_PROTOCOLS = new Set(["http:", "https:"]);
+
+const isPopupUrl = (rawUrl: string): boolean => {
+ try {
+ return POPUP_PROTOCOLS.has(new URL(rawUrl).protocol);
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * Preferences for a popup a preview page opens.
+ *
+ * A popup is not a webview attach, so the `will-attach-webview` hardening in
+ * `DesktopWindow` never sees it, and an unoverridden child would inherit the
+ * guest's relaxed posture: the picker preload needs `contextIsolation: false`
+ * to share `globalThis` with the previewed page, and no OAuth provider should
+ * get that. The window keeps the opener and the guest session either way.
+ */
+const POPUP_WINDOW_OPTIONS = {
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ },
+} satisfies Electron.BrowserWindowConstructorOptions;
+
+/**
+ * Decides what a preview page's `window.open` should do.
+ *
+ * `"popup"` opens a real window, which scripted popups need: denying them makes
+ * `window.open()` return `null` (OAuth SDKs report that as a blocked popup), and
+ * navigating the preview tab instead destroys the opener the popup has to
+ * `postMessage` its result back to.
+ *
+ * `target="_blank"` links arrive as a tab disposition and keep loading in the
+ * preview tab, which is what people expect from a link inside a preview.
+ */
+export const previewWindowOpenAction = (details: {
+ readonly url: string;
+ readonly disposition: Electron.HandlerDetails["disposition"];
+}): "popup" | "navigate" =>
+ details.disposition === "new-window" && isPopupUrl(details.url) ? "popup" : "navigate";
+
export const isPreviewRefreshShortcut = (input: Electron.Input): boolean =>
input.type === "keyDown" &&
input.key.toLowerCase() === "r" &&
@@ -1661,6 +1716,12 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
],
});
});
+ // A popup opens with Electron's default handler, so the page inside it could
+ // otherwise spawn native windows without limit. Nothing in an OAuth flow
+ // opens a second popup, so the chain stops at the first one.
+ const windowCreated = (window: Electron.BrowserWindow): void => {
+ window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
+ };
const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
if (isPreviewRefreshShortcut(input)) {
event.preventDefault();
@@ -1686,6 +1747,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.off("did-stop-loading", sync);
wc.off("did-fail-load", failed as never);
wc.off("audio-state-changed", audioStateChanged);
+ wc.off("did-create-window", windowCreated);
wc.off("before-input-event", beforeInput);
wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
@@ -1704,14 +1766,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
wc.on("audio-state-changed", audioStateChanged);
wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
- wc.setWindowOpenHandler(({ url }) => {
+ wc.setWindowOpenHandler((details) => {
+ if (previewWindowOpenAction(details) === "popup") {
+ return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS };
+ }
runFork(
attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () =>
- wc.loadURL(url),
+ wc.loadURL(details.url),
).pipe(Effect.ignore),
);
return { action: "deny" };
});
+ wc.on("did-create-window", windowCreated);
wc.on("before-input-event", beforeInput);
});
yield* Ref.update(attachedRef, (attached) =>
@@ -4089,7 +4155,7 @@ export class PreviewManager extends Context.Service<
) => Effect.Effect;
readonly automationStatus: (
tabId: string,
- ) => Effect.Effect;
+ ) => Effect.Effect;
readonly automationSnapshot: (
tabId: string,
) => Effect.Effect;
diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 13383b2465..28addfb25c 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -22,6 +22,7 @@ const clientSettings: ClientSettings = {
confirmThreadArchive: true,
confirmThreadDelete: false,
contextMenuStyle: "default",
+ confirmThreadUnpin: false,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
environmentIdentificationMode: "artwork",
diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts
index 895d246e36..1c6b64464d 100644
--- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts
+++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts
@@ -1,5 +1,7 @@
+// @effect-diagnostics nodeBuiltinImport:off - the executed suite runs the generated install script through a real POSIX shell.
import { describe, it } from "@effect/vitest";
-import { expect } from "vite-plus/test";
+import { afterAll, expect } from "vite-plus/test";
+import * as NodeChildProcess from "node:child_process";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
@@ -11,6 +13,9 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import {
buildWslNodeEnvPreamble,
+ buildWslRuntimeInstallScript,
+ buildWslRuntimeInvalidateScript,
+ buildWslRuntimePruneScript,
DesktopWslDistroListError,
formatMissingToolsReason,
formatNodePtyProbeFailureReason,
@@ -19,11 +24,62 @@ import {
parseNodeVersion,
parseResolvedPath,
parseToolchainReport,
+ parseWslRuntimeRoot,
probeWslDistros,
+ sanitizeWslRuntimeId,
} from "./DesktopWslEnvironment.ts";
const encoder = new TextEncoder();
+// The install script only fails the way this file cares about when a real shell
+// runs it, so find one that has the tools it needs: bash directly on Linux, and
+// the WSL distro on a Windows dev box, where Git Bash ships no flock. Anywhere
+// else the executed suite skips and the generated-text assertions stand alone.
+const REQUIRED_SHELL_TOOLS = ["flock", "sha256sum", "tar", "mktemp"] as const;
+
+const posixShellRunner = (() => {
+ // Candidates rather than a platform switch: wsl.exe simply fails to spawn
+ // where it does not exist, which is the same answer as a shell missing flock.
+ const candidates = [
+ { file: "bash", args: [] as ReadonlyArray },
+ { file: "wsl.exe", args: ["-e", "bash"] as ReadonlyArray },
+ ];
+ const probe = [
+ "[ -d /proc/1 ] || exit 1",
+ ...REQUIRED_SHELL_TOOLS.map((tool) => `command -v ${tool} >/dev/null || exit 1`),
+ ].join("\n");
+ return (
+ candidates.find((candidate) => {
+ const result = NodeChildProcess.spawnSync(candidate.file, [...candidate.args, "-c", probe], {
+ encoding: "utf8",
+ });
+ return result.status === 0;
+ }) ?? null
+ );
+})();
+
+const runShell = (script: string) => {
+ if (posixShellRunner === null) throw new Error("no POSIX shell runner available");
+ // The install script arrives on stdin in production too, which is what lets
+ // its own /proc scan not match itself.
+ const result = NodeChildProcess.spawnSync(
+ posixShellRunner.file,
+ [...posixShellRunner.args, "-s"],
+ { input: script, encoding: "utf8" },
+ );
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
+};
+
+const sh = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
+
+const readField = (stdout: string, field: string) => {
+ const line = stdout.split("\n").find((candidate) => candidate.startsWith(`${field}:`));
+ if (line === undefined) throw new Error(`missing ${field} in fixture output: ${stdout}`);
+ return line.slice(field.length + 1).trim();
+};
+
+const SERVER_ENTRY_SOURCE = 'console.log("t3code wsl runtime test server");';
+
const makeDistroListSpawner = (result: { readonly stdout?: string; readonly exitCode?: number }) =>
ChildProcessSpawner.make(() =>
Effect.succeed(
@@ -125,6 +181,572 @@ describe("buildWslNodeEnvPreamble", () => {
});
});
+describe("WSL runtime cache", () => {
+ it("sanitizes cache ids before interpolating them into Linux paths", () => {
+ expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope");
+ });
+
+ it("installs through a temporary directory and only reuses valid completed caches", () => {
+ const script = buildWslRuntimeInstallScript(
+ "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
+ "1.2.3-x64",
+ "b".repeat(64),
+ );
+
+ expect(script).toContain('runtime_parent="$HOME/.t3/wsl-runtime"');
+ expect(script).toContain(' [ -f "$ready_marker" ] &&');
+ expect(script).toContain(' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&');
+ expect(script).toContain(' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&');
+ expect(script).toContain(' node_pty_payload_present "$runtime_root"');
+ expect(script).not.toContain("node_modules/effect/package.json");
+ expect(script).toContain("if runtime_is_ready; then");
+ expect(script).toContain("trap 'exit 1' HUP INT TERM");
+ expect(script).toContain('exec 9> "$runtime_lock"');
+ expect(script).toContain("flock -x 9");
+ expect(script).not.toContain("runtime_lock_pid");
+ expect(script).not.toContain("sleep 0.1");
+ expect(script).not.toContain('rm -rf "$runtime_lock"');
+ expect(script).toContain('mv -T "$runtime_root" "$runtime_stale"');
+ expect(script).toContain('mktemp -d "$runtime_parent/.1.2.3-x64.tmp.XXXXXX"');
+ expect(script).toContain(
+ "tar -xzf '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' -C \"$runtime_tmp\"",
+ );
+ expect(script).toContain('test -f "$runtime_tmp/apps/server/dist/bin.mjs"');
+ expect(script).toContain('test -f "$runtime_tmp/node_modules/node-pty/package.json"');
+ expect(script).toContain('mv -T "$runtime_tmp" "$runtime_root"');
+ expect(script).not.toContain('rm -rf "$runtime_root"');
+
+ const lockAcquired = script.indexOf("flock -x 9");
+ const readinessAfterLock = script.indexOf("if runtime_is_ready; then", lockAcquired + 1);
+ const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
+ expect(lockAcquired).toBeGreaterThan(-1);
+ expect(readinessAfterLock).toBeGreaterThan(lockAcquired);
+ expect(existingRuntimeMoved).toBeGreaterThan(readinessAfterLock);
+ });
+
+ it("verifies the archive digest before extracting, and only on a cache miss", () => {
+ const script = buildWslRuntimeInstallScript(
+ "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
+ "1.2.3-x64",
+ "b".repeat(64),
+ );
+
+ const expected = "b".repeat(64);
+ expect(script).toContain(
+ "archive_sha=$(sha256sum '/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz' | cut -d ' ' -f 1)",
+ );
+ expect(script).toContain(`if [ "$archive_sha" != '${expected}' ]; then`);
+
+ // A warm cache exits before the hash, so reuse never pays for it, and the
+ // mismatch check runs before anything mutates the cache.
+ const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
+ const digestChecked = script.indexOf("archive_sha=$(sha256sum");
+ const existingRuntimeMoved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
+ const extracted = script.indexOf("tar -xzf");
+ expect(digestChecked).toBeGreaterThan(readyShortCircuit);
+ expect(existingRuntimeMoved).toBeGreaterThan(digestChecked);
+ expect(extracted).toBeGreaterThan(digestChecked);
+ });
+
+ // Invalidation revokes the ready marker without stopping the backend that
+ // failed the probe, so the next install can find an unready tree that a live
+ // process is still running out of. Deleting it there unlinks node_modules
+ // under that process; the pruner already refuses to touch in-use caches, and
+ // the install path has to refuse too.
+ it("moves an in-use runtime aside instead of deleting it under a live backend", () => {
+ const script = buildWslRuntimeInstallScript(
+ "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
+ "sha256-" + "c".repeat(64),
+ "b".repeat(64),
+ );
+
+ expect(script).toContain('grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null');
+ // No /proc means no way to tell, and guessing wrong costs a backend its
+ // runtime, so an unknowable answer has to count as in use.
+ expect(script).toContain(" [ -d /proc/1 ] || return 0");
+ expect(script).toContain(' if runtime_in_use "$runtime_root"; then');
+
+ // A process's cmdline keeps the pre-rename path, so the question is only
+ // answerable before the move.
+ const inUseChecked = script.indexOf('if runtime_in_use "$runtime_root"; then');
+ const moved = script.indexOf('mv -T "$runtime_root" "$runtime_stale"');
+ expect(inUseChecked).toBeGreaterThan(-1);
+ expect(inUseChecked).toBeLessThan(moved);
+
+ // In use: keep the tree and restart the sweep's clock, because renaming
+ // preserves the directory's mtime and a long-installed tree would otherwise
+ // already be past the age gate. Idle: delete it now, as before.
+ const kept = script.indexOf('touch "$runtime_stale"');
+ const deleted = script.indexOf('rm -rf "$runtime_stale"');
+ expect(kept).toBeGreaterThan(moved);
+ expect(deleted).toBeGreaterThan(kept);
+ });
+
+ it("treats a runtime whose native payload went missing as a cache miss", () => {
+ const script = buildWslRuntimeInstallScript(
+ "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
+ "1.2.3-x64",
+ "b".repeat(64),
+ );
+
+ // A glob, not a mapped `uname -m`: this is a presence check, and the later
+ // native probe is what judges arch and loadability.
+ expect(script).toContain(
+ ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',
+ );
+ // The marker the probe reads must sit beside the binary, or the runtime is
+ // just as unusable as one missing pty.node outright.
+ expect(script).toContain(' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue');
+
+ // Readiness gates the short-circuit, so a cache missing the payload
+ // reinstalls from the archive instead of being reused forever.
+ const payloadCheckDefined = script.indexOf("node_pty_payload_present() {");
+ const readinessDefined = script.indexOf("runtime_is_ready() {");
+ const readyShortCircuit = script.indexOf("if runtime_is_ready; then");
+ expect(payloadCheckDefined).toBeGreaterThan(-1);
+ expect(payloadCheckDefined).toBeLessThan(readinessDefined);
+ expect(readinessDefined).toBeLessThan(readyShortCircuit);
+ });
+
+ // A truncated or half-written bin.mjs passes every presence check the cache
+ // had: the file exists, node-pty still loads, and launch then picks a server
+ // that exits before it becomes ready — forever, because nothing ever
+ // reinstalls. The digest the install records is what turns that into a miss.
+ it("re-hashes the server entry against the digest the install recorded", () => {
+ const script = buildWslRuntimeInstallScript(
+ "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
+ "1.2.3-x64",
+ "b".repeat(64),
+ );
+
+ expect(script).toContain(
+ ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,
+ );
+ expect(script).toContain(
+ ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
+ );
+ // A runtime installed before the marker carried a digest reads as empty,
+ // which has to be a miss rather than a pass.
+ expect(script).toContain(' [ -n "$recorded_entry_digest" ] &&');
+ expect(script).toContain(
+ `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/.t3code-wsl-runtime-ready"`,
+ );
+
+ // The digest is recorded after extraction and before promotion.
+ const extracted = script.indexOf("tar -xzf");
+ const digestRecorded = script.indexOf(
+ 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',
+ );
+ const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
+ const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
+ expect(digestRecorded).toBeGreaterThan(extracted);
+ expect(markerWritten).toBeGreaterThan(digestRecorded);
+ expect(promoted).toBeGreaterThan(markerWritten);
+ });
+
+ it("refuses to mark an archive without a native payload as ready", () => {
+ const script = buildWslRuntimeInstallScript(
+ "/mnt/c/Program Files/T3 Code/wsl-runtime.tar.gz",
+ "1.2.3-x64",
+ "b".repeat(64),
+ );
+
+ expect(script).toContain('if ! node_pty_payload_present "$runtime_tmp"; then');
+
+ // The extracted tree is rejected before the ready marker is written, so a
+ // defective archive falls back to the mounted tree instead of caching.
+ const payloadValidated = script.indexOf('node_pty_payload_present "$runtime_tmp"');
+ const markerWritten = script.indexOf('> "$runtime_tmp/.t3code-wsl-runtime-ready"');
+ const promoted = script.indexOf('mv -T "$runtime_tmp" "$runtime_root"');
+ expect(payloadValidated).toBeGreaterThan(-1);
+ expect(markerWritten).toBeGreaterThan(payloadValidated);
+ expect(promoted).toBeGreaterThan(payloadValidated);
+ });
+
+ it("parses only absolute Linux runtime paths", () => {
+ expect(parseWslRuntimeRoot("runtimeRoot:/home/josh/.t3/wsl-runtime/1.2.3-x64\n")).toBe(
+ "/home/josh/.t3/wsl-runtime/1.2.3-x64",
+ );
+ expect(parseWslRuntimeRoot("runtimeRoot:relative/path\n")).toBeNull();
+ expect(parseWslRuntimeRoot("noise\n")).toBeNull();
+ });
+
+ it("prunes completed runtimes except the current and newest previous cache", () => {
+ const script = buildWslRuntimePruneScript("1.2.3/x64");
+
+ expect(script).toContain('current_runtime="$runtime_parent/1.2.3_x64"');
+ expect(script).toContain('[ "$candidate" -nt "$previous_runtime" ]');
+ expect(script).toContain('[ "$candidate" != "$current_runtime" ] || continue');
+ expect(script).toContain('[ "$candidate" != "$previous_runtime" ] || continue');
+ expect(script).toContain('[ -f "$candidate/.t3code-wsl-runtime-ready" ] || continue');
+ expect(script).toContain('rm -rf -- "$candidate"');
+ });
+
+ it("never deletes a runtime another backend is running from", () => {
+ const script = buildWslRuntimePruneScript("1.2.3/x64");
+
+ // The running backend's argv holds `/apps/server/dist/bin.mjs`, so
+ // the process itself is the lease and exiting releases it. Nothing has to be
+ // registered up front, which is what makes this cover backends already
+ // running from an older version that knows nothing about pruning.
+ expect(script).toContain(' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null');
+ expect(script).toContain(' ! runtime_in_use "$candidate" || continue');
+
+ // Without visible processes the retention rules cannot tell a live cache
+ // from an abandoned one, so the sweep is skipped rather than guessed at.
+ expect(script).toContain("[ -d /proc/1 ] || exit 0");
+
+ // The guard has to gate the delete, not just exist.
+ const inUseChecked = script.indexOf('! runtime_in_use "$candidate"');
+ const removed = script.indexOf('rm -rf -- "$candidate"');
+ expect(inUseChecked).toBeGreaterThan(-1);
+ expect(removed).toBeGreaterThan(inUseChecked);
+ });
+
+ it("sweeps orphaned install scratch directories the ready-marker loops cannot see", () => {
+ const script = buildWslRuntimePruneScript("1.2.3/x64");
+
+ // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry
+ // no ready marker either; without this pass a killed install leaks forever.
+ expect(script).toContain(
+ 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',
+ );
+ // Age guard: a scratch directory younger than this belongs to a live install.
+ expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120');
+ });
+
+ it("invalidates a cache by dropping its ready marker, not the tree", () => {
+ const script = buildWslRuntimeInvalidateScript("1.2.3/x64");
+
+ // Readiness is a presence check, so a tree whose pty.node is present but
+ // unloadable stays ready forever unless the probe can revoke the marker.
+ expect(script).toContain('rm -f "$HOME/.t3/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"');
+ // Deleting the tree here would pull it out from under any backend still
+ // running from it; the next install moves an unready root aside instead.
+ expect(script).not.toContain("rm -rf");
+ });
+});
+
+// Reading the generated script proves what it says, not what it does. A cache
+// whose bin.mjs was truncated satisfied every assertion above and still got
+// reused, so these run the real script against a real archive in a throwaway
+// HOME and check the outcome.
+describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed)", () => {
+ const fixtures: Array = [];
+
+ afterAll(() => {
+ for (const work of fixtures) runShell(`set -eu\nrm -rf ${sh(work)}`);
+ fixtures.length = 0;
+ });
+
+ const createFixture = () => {
+ const result = runShell(
+ [
+ "set -eu",
+ "work=$(mktemp -d)",
+ 'stage="$work/stage"',
+ 'mkdir -p "$stage/apps/server/dist" "$stage/node_modules/node-pty/prebuilds/linux-x64" "$work/home"',
+ `printf '%s' ${sh(SERVER_ENTRY_SOURCE)} > "$stage/apps/server/dist/bin.mjs"`,
+ `printf '%s' '{"name":"node-pty","version":"0.0.0-test"}' > "$stage/node_modules/node-pty/package.json"`,
+ `printf '%s' 'pty-native-payload' > "$stage/node_modules/node-pty/prebuilds/linux-x64/pty.node"`,
+ `printf '%s' '{"arch":"x64"}' > "$stage/node_modules/node-pty/prebuilds/linux-x64/t3code-wsl-node-pty.json"`,
+ `tar -czf "$work/wsl-runtime.tar.gz" -C "$stage" apps/server/dist node_modules`,
+ `printf 'work:%s\\n' "$work"`,
+ `printf 'archiveSha:%s\\n' "$(sha256sum "$work/wsl-runtime.tar.gz" | cut -d ' ' -f 1)"`,
+ ].join("\n"),
+ );
+ expect(result.status, result.stderr).toBe(0);
+
+ const work = readField(result.stdout, "work");
+ fixtures.push(work);
+ const archivePath = `${work}/wsl-runtime.tar.gz`;
+ const archiveSha = readField(result.stdout, "archiveSha");
+ const runtimeId = `sha256-${archiveSha}`;
+ // The script reads $HOME, and WSL does not inherit the parent process's
+ // environment, so the home override rides in the script itself.
+ const installScript = (archive = archivePath, sha = archiveSha) =>
+ [
+ `HOME=${sh(`${work}/home`)}`,
+ "export HOME",
+ buildWslRuntimeInstallScript(archive, runtimeId, sha),
+ ].join("\n");
+ return {
+ work,
+ archivePath,
+ archiveSha,
+ runtimeId,
+ runtimeParent: `${work}/home/.t3/wsl-runtime`,
+ runtimeRoot: `${work}/home/.t3/wsl-runtime/${runtimeId}`,
+ serverEntry: `${work}/home/.t3/wsl-runtime/${runtimeId}/apps/server/dist/bin.mjs`,
+ installScript,
+ install: (archive?: string, sha?: string) => runShell(installScript(archive, sha)),
+ };
+ };
+
+ it("reuses a warm cache without touching the archive", () => {
+ const fixture = createFixture();
+ expect(fixture.install().status).toBe(0);
+ // Deleting the archive is how the test tells reuse apart from a silent
+ // reinstall: only the warm path can succeed without it.
+ expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0);
+
+ const warm = fixture.install();
+
+ expect(warm.status, warm.stderr).toBe(0);
+ expect(parseWslRuntimeRoot(warm.stdout)).toBe(fixture.runtimeRoot);
+ });
+
+ it("reinstalls a cache whose server entry was truncated", () => {
+ const fixture = createFixture();
+ expect(fixture.install().status).toBe(0);
+ expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
+
+ const repaired = fixture.install();
+
+ expect(repaired.status, repaired.stderr).toBe(0);
+ expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot);
+ const restored = runShell(`set -eu\ncat ${sh(fixture.serverEntry)}`);
+ expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE);
+ });
+
+ it("falls back instead of launching a corrupted cache it cannot reinstall", () => {
+ const fixture = createFixture();
+ expect(fixture.install().status).toBe(0);
+ expect(runShell(`set -eu\n: > ${sh(fixture.serverEntry)}`).status).toBe(0);
+ expect(runShell(`set -eu\nrm ${sh(fixture.archivePath)}`).status).toBe(0);
+
+ const broken = fixture.install();
+
+ // Non-zero with no runtimeRoot is what sends the backend to the mounted
+ // server tree. Exiting 0 here is the bug: launch would pick the zero-byte
+ // server, fail to become ready, and do it again on every restart.
+ expect(broken.status).not.toBe(0);
+ expect(parseWslRuntimeRoot(broken.stdout)).toBeNull();
+ });
+
+ it("extracts once when two installs race for the same cache", () => {
+ const fixture = createFixture();
+ // A tar shim counts extractions and holds the critical section open long
+ // enough that the second install is certain to arrive while the first is
+ // still inside it. One extraction is the answer either way the runs
+ // interleave: whoever waits for the lock re-checks readiness before
+ // spending an extract, so a broken lock shows up as two.
+ const raced = runShell(
+ [
+ "set -eu",
+ `work=${sh(fixture.work)}`,
+ 'mkdir -p "$work/bin"',
+ "real_tar=$(command -v tar)",
+ `printf '#!/bin/sh\\nprintf x >> "%s/tar-calls"\\nsleep 1\\nexec %s "$@"\\n' "$work" "$real_tar" > "$work/bin/tar"`,
+ 'chmod +x "$work/bin/tar"',
+ ': > "$work/tar-calls"',
+ 'PATH="$work/bin:$PATH"',
+ "export PATH",
+ `cat > "$work/install.sh" <<'T3CODE_INSTALL_SCRIPT'`,
+ fixture.installScript(),
+ "T3CODE_INSTALL_SCRIPT",
+ // Both racers run the same file, and neither file path contains the
+ // runtime root, so the script's own /proc scan cannot see them.
+ 'sh "$work/install.sh" > "$work/first.out" 2>&1 &',
+ "first=$!",
+ 'sh "$work/install.sh" > "$work/second.out" 2>&1 &',
+ "second=$!",
+ "if wait $first; then first_status=0; else first_status=$?; fi",
+ "if wait $second; then second_status=0; else second_status=$?; fi",
+ `printf 'firstStatus:%s\\n' "$first_status"`,
+ `printf 'secondStatus:%s\\n' "$second_status"`,
+ `printf 'extractions:%s\\n' "$(wc -c < "$work/tar-calls" | tr -d ' ')"`,
+ `printf 'firstRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/first.out")"`,
+ `printf 'secondRoot:%s\\n' "$(sed -n 's/^runtimeRoot://p' "$work/second.out")"`,
+ ].join("\n"),
+ );
+
+ expect(raced.status, raced.stderr).toBe(0);
+ expect(readField(raced.stdout, "firstStatus")).toBe("0");
+ expect(readField(raced.stdout, "secondStatus")).toBe("0");
+ expect(readField(raced.stdout, "extractions")).toBe("1");
+ expect(readField(raced.stdout, "firstRoot")).toBe(fixture.runtimeRoot);
+ expect(readField(raced.stdout, "secondRoot")).toBe(fixture.runtimeRoot);
+ });
+
+ it("leaves no half-built cache when extraction fails", () => {
+ const fixture = createFixture();
+ // Truncating the archive and re-recording its digest gets the install past
+ // the digest gate and into a tar that dies mid-stream, which is what a full
+ // disk or an interrupted write looks like from inside the distro.
+ const truncated = runShell(
+ [
+ "set -eu",
+ `work=${sh(fixture.work)}`,
+ 'size=$(wc -c < "$work/wsl-runtime.tar.gz")',
+ 'head -c $((size / 2)) "$work/wsl-runtime.tar.gz" > "$work/truncated.tar.gz"',
+ `printf 'sha:%s\\n' "$(sha256sum "$work/truncated.tar.gz" | cut -d ' ' -f 1)"`,
+ ].join("\n"),
+ );
+ expect(truncated.status, truncated.stderr).toBe(0);
+
+ const failed = fixture.install(
+ `${fixture.work}/truncated.tar.gz`,
+ readField(truncated.stdout, "sha"),
+ );
+
+ expect(failed.status).not.toBe(0);
+ expect(parseWslRuntimeRoot(failed.stdout)).toBeNull();
+ // A partial extract that survived under the cache name would be promoted by
+ // the next launch's readiness check; scratch that survived would sit there
+ // until the pruner's age sweep. Neither is left behind. Only directories
+ // are counted: the empty flock file stays on purpose, which is what keeps
+ // the lock from carrying stale state across a killed install.
+ const leftovers = runShell(
+ `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`,
+ );
+ expect(leftovers.stdout.trim()).toBe("");
+ });
+
+ // The archive and the identity recorded beside it can diverge — a partial
+ // download, or a rebuilt archive dropped next to an older sidecar. Either
+ // gate firing means the bytes never reach the cache under a name that claims
+ // to describe something else.
+ it("refuses an archive whose bytes do not match the digest recorded for it", () => {
+ const fixture = createFixture();
+
+ const refused = fixture.install(fixture.archivePath, "e".repeat(64));
+
+ expect(refused.status).not.toBe(0);
+ expect(refused.stderr).toContain("does not match its recorded SHA-256");
+ expect(parseWslRuntimeRoot(refused.stdout)).toBeNull();
+ const leftovers = runShell(
+ `set -eu\nfind ${sh(fixture.runtimeParent)} -mindepth 1 -maxdepth 1 -type d`,
+ );
+ expect(leftovers.stdout.trim()).toBe("");
+ });
+
+ it("leases a warm cache across the prepare-to-spawn handoff", () => {
+ const fixture = createFixture();
+ expect(fixture.install().status).toBe(0);
+ const selected = runShell(
+ [
+ "set -eu",
+ `runtime_parent=${sh(fixture.runtimeParent)}`,
+ 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"',
+ 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"',
+ 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"',
+ `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`,
+ 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
+ `cat > ${sh(`${fixture.work}/select.sh`)} <<'T3CODE_SELECT_SCRIPT'`,
+ fixture.installScript(),
+ "T3CODE_SELECT_SCRIPT",
+ `sh ${sh(`${fixture.work}/select.sh`)}`,
+ `HOME=${sh(`${fixture.work}/home`)}`,
+ "export HOME",
+ buildWslRuntimePruneScript("sha256-current"),
+ `test -d ${sh(fixture.runtimeRoot)}`,
+ ].join("\n"),
+ );
+
+ expect(selected.status, `${selected.stdout}\n${selected.stderr}`).toBe(0);
+ });
+
+ it("prunes a selected cache after its prepare-to-spawn grace period expires", () => {
+ const fixture = createFixture();
+ expect(fixture.install().status).toBe(0);
+ const result = runShell(
+ [
+ "set -eu",
+ `runtime_parent=${sh(fixture.runtimeParent)}`,
+ 'mkdir -p "$runtime_parent/sha256-current" "$runtime_parent/sha256-previous"',
+ 'printf ready > "$runtime_parent/sha256-current/.t3code-wsl-runtime-ready"',
+ 'printf ready > "$runtime_parent/sha256-previous/.t3code-wsl-runtime-ready"',
+ `touch -d "10 minutes ago" ${sh(fixture.runtimeRoot)}`,
+ `touch -d "10 minutes ago" ${sh(`${fixture.runtimeRoot}/.t3code-wsl-runtime-selected`)}`,
+ 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
+ `HOME=${sh(`${fixture.work}/home`)}`,
+ "export HOME",
+ buildWslRuntimePruneScript("sha256-current"),
+ `test ! -e ${sh(fixture.runtimeRoot)}`,
+ ].join("\n"),
+ );
+
+ expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
+ });
+
+ it("removes an aged stale tree after replacing an active unready cache", () => {
+ const fixture = createFixture();
+ expect(fixture.install().status).toBe(0);
+ const result = runShell(
+ [
+ "set -eu",
+ `runtime_root=${sh(fixture.runtimeRoot)}`,
+ `runtime_parent=${sh(fixture.runtimeParent)}`,
+ 'rm "$runtime_root/.t3code-wsl-runtime-ready"',
+ 'sh -c "sleep 30" "$runtime_root/apps/server/dist/bin.mjs" >/dev/null 2>&1 &',
+ "active_pid=$!",
+ "sleep 0.1",
+ fixture.installScript(),
+ 'stale=$(find "$runtime_parent" -maxdepth 1 -type d -name ".sha256-*.stale.*" -print -quit)',
+ 'test -n "$stale"',
+ 'touch -d "180 minutes ago" "$stale"',
+ `HOME=${sh(`${fixture.work}/home`)}`,
+ "export HOME",
+ buildWslRuntimePruneScript(fixture.runtimeId),
+ 'test ! -e "$stale"',
+ "kill $active_pid",
+ "wait $active_pid 2>/dev/null || true",
+ ].join("\n"),
+ );
+
+ expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
+ });
+
+ it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => {
+ const result = runShell(
+ [
+ "set -eu",
+ "work=$(mktemp -d)",
+ 'home="$work/home"',
+ 'runtime_parent="$home/.t3/wsl-runtime"',
+ 'mkdir -p "$runtime_parent"',
+ 'make_ready() { mkdir -p "$runtime_parent/$1/apps/server/dist"; printf ready > "$runtime_parent/$1/.t3code-wsl-runtime-ready"; }',
+ "make_ready sha256-current",
+ "make_ready sha256-previous",
+ "make_ready sha256-active",
+ "make_ready sha256-old",
+ "make_ready sha256-locked",
+ 'mkdir -p "$runtime_parent/sha256-markerless" "$runtime_parent/versions"',
+ 'touch -d "1 minute ago" "$runtime_parent/sha256-previous"',
+ 'touch -d "4 minutes ago" "$runtime_parent/sha256-active"',
+ 'touch -d "3 minutes ago" "$runtime_parent/sha256-old"',
+ 'touch -d "2 minutes ago" "$runtime_parent/sha256-locked"',
+ 'sh -c "sleep 30" "$runtime_parent/sha256-active/apps/server/dist/bin.mjs" >/dev/null 2>&1 &',
+ "active_pid=$!",
+ "(",
+ ' exec 9> "$runtime_parent/.sha256-locked.install.lock"',
+ " flock -x 9",
+ " sleep 30",
+ ") >/dev/null 2>&1 &",
+ "lock_pid=$!",
+ "sleep 0.1",
+ `HOME="$home"`,
+ "export HOME",
+ buildWslRuntimePruneScript("sha256-current"),
+ 'test -d "$runtime_parent/sha256-current"',
+ 'test -d "$runtime_parent/sha256-previous"',
+ 'test -d "$runtime_parent/sha256-active"',
+ 'test -d "$runtime_parent/sha256-locked"',
+ 'test -d "$runtime_parent/versions"',
+ 'test ! -e "$runtime_parent/sha256-old"',
+ 'test ! -e "$runtime_parent/sha256-markerless"',
+ "kill $active_pid $lock_pid",
+ "wait $active_pid 2>/dev/null || true",
+ "wait $lock_pid 2>/dev/null || true",
+ 'rm -rf "$work"',
+ ].join("\n"),
+ );
+
+ expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
+ });
+});
+
describe("parseToolchainReport", () => {
it("returns no missing tools and no node version on empty output", () => {
expect(parseToolchainReport("")).toEqual({ missingTools: [], nodeVersion: null });
diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts
index dfd0c8eb47..56ccd5a8a2 100644
--- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts
+++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts
@@ -22,6 +22,9 @@ const WSLPATH_TIMEOUT = Duration.seconds(10);
const PROBE_TIMEOUT = Duration.seconds(10);
const TOOLCHAIN_TIMEOUT = Duration.seconds(10);
const BUILD_TIMEOUT = Duration.minutes(5);
+const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2);
+const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30);
+const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15);
const USER_HOME_TIMEOUT = Duration.seconds(5);
const TOOLCHAIN_TRANSPORT_RETRY_LIMIT = 12;
const BUILD_TRANSPORT_RETRY_LIMIT = 2;
@@ -31,6 +34,25 @@ export interface EnsureWslNodePtyOptions {
readonly nodeEngineRange?: string | null;
}
+// The packaged WSL runtime archive plus the SHA-256 identity the build recorded
+// for it. The cache key derives from the same digest, and installation verifies
+// the bytes before promoting the extracted tree.
+export interface WslRuntimeArchive {
+ readonly windowsPath: string;
+ readonly runtimeId: string;
+ readonly sha256: string;
+}
+
+export type PrepareWslRuntimeResult =
+ | {
+ readonly ok: true;
+ readonly linuxAppRoot: string;
+ }
+ | {
+ readonly ok: false;
+ readonly reason: string;
+ };
+
export type EnsureWslNodePtyResult =
| {
readonly ok: true;
@@ -79,9 +101,16 @@ export class DesktopWslEnvironment extends Context.Service<
// (the backend can be listening for 30+ seconds before wslhost starts
// forwarding 127.0.0.1:port to WSL-side localhost).
readonly getDistroIp: (distro: string | null) => Effect.Effect>;
+ readonly prepareRuntime: (
+ distro: string | null,
+ archive: WslRuntimeArchive,
+ ) => Effect.Effect;
+ readonly pruneRuntimes: (distro: string | null, runtimeId: string) => Effect.Effect;
+ // Marks a staged runtime as unusable so the next launch reinstalls it.
+ readonly invalidateRuntime: (distro: string | null, runtimeId: string) => Effect.Effect;
readonly ensureNodePty: (
distro: string | null,
- windowsRepoRoot: string,
+ linuxAppRoot: string,
options?: EnsureWslNodePtyOptions,
) => Effect.Effect;
}
@@ -149,18 +178,28 @@ const runWslShell = (
distro: string | null,
bashScript: string,
timeout: Duration.Duration,
- options: EnsureWslNodePtyOptions = {},
+ options: {
+ readonly nodeEngineRange?: string | null;
+ readonly resolveNode?: boolean;
+ } = {},
): Effect.Effect => {
const spawner = ChildProcessSpawner.ChildProcessSpawner;
- // -l picks up profile-managed PATH; the shared resolver covers supported
- // version managers that non-interactive login shells can miss. -s so bash
- // reads the script from stdin.
+ // Node probes use a login bash so profile-managed PATH entries and supported
+ // version managers are available. Runtime installation needs only POSIX tools,
+ // so it skips profile loading and runs sh directly.
+ const resolveNode = options.resolveNode !== false;
const command = ChildProcess.make(
"wsl.exe",
- [...buildDistroArgs(distro), "--", "bash", "-l", "-s"],
+ resolveNode
+ ? [...buildDistroArgs(distro), "--", "bash", "-l", "-s"]
+ : [...buildDistroArgs(distro), "--exec", "sh", "-s"],
{
stdin: Stream.encodeText(
- Stream.make(`${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`),
+ Stream.make(
+ resolveNode
+ ? `${buildWslNodeEnvPreamble(options.nodeEngineRange)}${bashScript}`
+ : bashScript,
+ ),
),
stdout: "pipe",
stderr: "pipe",
@@ -216,6 +255,240 @@ const runWslShell = (
const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
+// Holds the sha256 of the runtime's server entry, written when the install
+// promotes a verified tree. Presence alone only says an install once finished
+// here; the digest is what lets a later launch prove the entry still is what
+// that install wrote.
+const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready";
+const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected";
+const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5;
+
+export const sanitizeWslRuntimeId = (value: string): string =>
+ value.replace(/[^A-Za-z0-9._-]/g, "_");
+
+// `archiveSha256` is the digest the build recorded alongside the archive. The
+// install verifies the bytes before extracting, so an archive can never be
+// promoted under an identity that does not describe it.
+export const buildWslRuntimeInstallScript = (
+ linuxArchivePath: string,
+ runtimeId: string,
+ archiveSha256: string,
+): string => {
+ const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
+ return [
+ "set -eu",
+ 'runtime_parent="$HOME/.t3/wsl-runtime"',
+ `runtime_root="$runtime_parent/${safeRuntimeId}"`,
+ `ready_marker="$runtime_root/${WSL_RUNTIME_READY_MARKER}"`,
+ // The native payload is the part of the tree the WSL backend actually
+ // dlopens, and the only part a user can plausibly break by hand. Checking
+ // node-pty's package.json alone let a runtime whose pty.node had gone
+ // missing stay cache-ready forever: every launch reused it and then failed
+ // the native probe, with no reinstall and no fallback. Match on the glob
+ // rather than a mapped `uname -m` so this stays a presence check; the probe
+ // is what decides whether the binary is the right arch and loadable.
+ "node_pty_payload_present() {",
+ ' for candidate in "$1"/node_modules/node-pty/prebuilds/linux-*/pty.node; do',
+ ' [ -f "$candidate" ] || continue',
+ ' [ -f "${candidate%/*}/t3code-wsl-node-pty.json" ] || continue',
+ " return 0",
+ " done",
+ " return 1",
+ "}",
+ // Hashing the server entry is the only check that can tell a working cache
+ // from one whose bin.mjs was truncated or half-written: the file is still
+ // there, the native probe still passes, and launch then picks a server that
+ // exits before it can become ready, on every restart. Hashing the ~7MB
+ // entry measures in single-digit milliseconds inside the distro, once per
+ // launch, against a cold reinstall of a few hundred megabytes.
+ "runtime_server_entry_digest() {",
+ ` sha256sum "$1/apps/server/dist/bin.mjs" 2>/dev/null | cut -d ' ' -f 1`,
+ "}",
+ "runtime_is_ready() {",
+ ' [ -f "$ready_marker" ] &&',
+ ' [ -f "$runtime_root/apps/server/dist/bin.mjs" ] &&',
+ ' [ -f "$runtime_root/node_modules/node-pty/package.json" ] &&',
+ ' node_pty_payload_present "$runtime_root" &&',
+ // An empty or unreadable marker is a miss, not a pass: that is what a
+ // runtime installed before the marker carried a digest looks like, and one
+ // reinstall is the cheapest way to make it verifiable from then on.
+ ` recorded_entry_digest=$(tr -d '[:space:]' < "$ready_marker" 2>/dev/null) &&`,
+ ' [ -n "$recorded_entry_digest" ] &&',
+ ' [ "$recorded_entry_digest" = "$(runtime_server_entry_digest "$runtime_root")" ]',
+ "}",
+ 'mkdir -p "$runtime_parent"',
+ `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`,
+ "trap 'exit 1' HUP INT TERM",
+ 'exec 9> "$runtime_lock"',
+ "flock -x 9",
+ "if runtime_is_ready; then",
+ ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
+ ` printf 'runtimeRoot:%s\\n' "$runtime_root"`,
+ " exit 0",
+ "fi",
+ // Hash only on a cache miss: a warm launch already exited above, and a cold
+ // install is about to read the whole archive through tar anyway. `set -eu`
+ // turns a distro without sha256sum into an install failure, which falls back
+ // to the mounted server tree rather than trusting unverified bytes.
+ `archive_sha=$(sha256sum ${shellQuote(linuxArchivePath)} | cut -d ' ' -f 1)`,
+ `if [ "$archive_sha" != ${shellQuote(archiveSha256)} ]; then`,
+ ` printf 'WSL runtime archive does not match its recorded SHA-256 (expected %s, got %s)\\n' ${shellQuote(archiveSha256)} "$archive_sha" >&2`,
+ " exit 1",
+ "fi",
+ // A backend can still be running out of an unready tree: the probe revokes
+ // the ready marker without stopping the process it just failed for, and
+ // invalidation deliberately leaves the tree in place for exactly that
+ // reason. Deleting it here unlinks node_modules from under a live backend,
+ // which then breaks the moment it lazily loads anything it had not already
+ // read. Move it aside either way, but only delete it now when nothing is
+ // running from it; otherwise hand it to the pruner's scratch sweep, which
+ // is what that delay is for. A process's cmdline keeps the pre-rename path,
+ // so this has to be asked before the move, not after. This script arrives
+ // on stdin, so it cannot match itself.
+ "runtime_in_use() {",
+ // No /proc means no way to tell, and guessing wrong costs a live backend
+ // its runtime. Keeping the tree only costs disk until the sweep runs.
+ " [ -d /proc/1 ] || return 0",
+ ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
+ "}",
+ 'if [ -e "$runtime_root" ]; then',
+ ' if runtime_in_use "$runtime_root"; then',
+ " runtime_root_in_use=1",
+ " else",
+ " runtime_root_in_use=0",
+ " fi",
+ ` runtime_stale=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.stale.XXXXXX")`,
+ ' rmdir "$runtime_stale"',
+ ' if mv -T "$runtime_root" "$runtime_stale" 2>/dev/null; then',
+ ' if [ "$runtime_root_in_use" = 1 ]; then',
+ // Renaming keeps the directory's old mtime, so restart the cleanup clock.
+ ' touch "$runtime_stale"',
+ " else",
+ ' rm -rf "$runtime_stale"',
+ " fi",
+ " fi",
+ "fi",
+ `runtime_tmp=$(mktemp -d "$runtime_parent/.${safeRuntimeId}.tmp.XXXXXX")`,
+ 'cleanup_runtime_install() { rm -rf "$runtime_tmp"; }',
+ "trap cleanup_runtime_install EXIT",
+ `tar -xzf ${shellQuote(linuxArchivePath)} -C "$runtime_tmp"`,
+ 'test -f "$runtime_tmp/apps/server/dist/bin.mjs"',
+ 'test -f "$runtime_tmp/node_modules/node-pty/package.json"',
+
+ // Never write the ready marker over a tree that is missing the native
+ // payload. Failing here drops out to the mounted-tree fallback, which is
+ // recoverable; promoting it would mark the defect ready and cache it.
+ 'if ! node_pty_payload_present "$runtime_tmp"; then',
+ " printf 'WSL runtime archive is missing its Linux node-pty binary\\n' >&2",
+ " exit 1",
+ "fi",
+ // The archive's bytes were verified against archiveSha256 above, so the
+ // digest recorded here describes content this install proved. Every later
+ // warm reuse checks the entry against it.
+ 'installed_entry_digest=$(runtime_server_entry_digest "$runtime_tmp")',
+ 'if [ -z "$installed_entry_digest" ]; then',
+ " printf 'Could not hash the WSL runtime server entry\\n' >&2",
+ " exit 1",
+ "fi",
+ `printf '%s\\n' "$installed_entry_digest" > "$runtime_tmp/${WSL_RUNTIME_READY_MARKER}"`,
+ 'if mv -T "$runtime_tmp" "$runtime_root" 2>/dev/null; then',
+ " :",
+ "elif runtime_is_ready; then",
+ ' rm -rf "$runtime_tmp"',
+ "else",
+ ` printf 'Could not promote WSL runtime cache at %s\\n' "$runtime_root" >&2`,
+ " exit 1",
+ "fi",
+ `touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`,
+ `printf 'runtimeRoot:%s\\n' "$runtime_root"`,
+ ].join("\n");
+};
+
+// An interrupted install leaves a dot-prefixed scratch directory behind. A cold
+// install extracts a few hundred MB inside the distro, so two hours is far past
+// any live install while still bounding how long an orphan survives.
+const ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES = 120;
+
+export const buildWslRuntimePruneScript = (runtimeId: string): string => {
+ const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
+ return [
+ "set -eu",
+ 'runtime_parent="$HOME/.t3/wsl-runtime"',
+ `current_runtime="$runtime_parent/${safeRuntimeId}"`,
+ '[ -d "$runtime_parent" ] || exit 0',
+ // Serialize the whole retention decision so two backends cannot select
+ // different "previous" caches and delete around one another.
+ 'prune_lock="$runtime_parent/.prune.lock"',
+ 'exec 8> "$prune_lock"',
+ "flock -x 8",
+ // Without a way to see the distro's processes we cannot tell which caches
+ // are load-bearing, and the retention rules below are not safe on their own.
+ "[ -d /proc/1 ] || exit 0",
+ "runtime_in_use() {",
+ ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null',
+ "}",
+ 'previous_runtime=""',
+ 'for candidate in "$runtime_parent"/sha256-*; do',
+ ' [ -d "$candidate" ] || continue',
+ ' [ "$candidate" != "$current_runtime" ] || continue',
+ ` [ -f "$candidate/${WSL_RUNTIME_READY_MARKER}" ] || continue`,
+ ' if [ -z "$previous_runtime" ] || [ "$candidate" -nt "$previous_runtime" ]; then',
+ ' previous_runtime="$candidate"',
+ " fi",
+ "done",
+ // Only this desktop-owned prefix is eligible. Markerless roots are broken
+ // caches left by invalidation and must not become permanent disk leaks.
+ 'for candidate in "$runtime_parent"/sha256-*; do',
+ ' [ -d "$candidate" ] || continue',
+ ' [ "$candidate" != "$current_runtime" ] || continue',
+ ' [ "$candidate" != "$previous_runtime" ] || continue',
+ ' ! runtime_in_use "$candidate" || continue',
+ " candidate_name=${candidate##*/}",
+ ' candidate_lock="$runtime_parent/.${candidate_name}.install.lock"',
+ ' exec 9> "$candidate_lock"',
+ // A held lock means another launch is installing or repairing this cache.
+ // Skip instead of waiting or deleting underneath it.
+ " flock -n 9 || continue",
+ ` selected_marker="$candidate/${WSL_RUNTIME_SELECTED_MARKER}"`,
+ ` if [ -f "$selected_marker" ] && find "$selected_marker" -maxdepth 0 -mmin -${String(WSL_RUNTIME_SELECTION_GRACE_MINUTES)} -print -quit | grep -q .; then`,
+ " flock -u 9",
+ " continue",
+ " fi",
+ ' rm -rf -- "$candidate"',
+ " flock -u 9",
+ "done",
+ // Interrupted installs use dot-prefixed names under this dedicated parent.
+ 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do',
+ ' [ -d "$scratch" ] || continue',
+ ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`,
+ ' rm -rf -- "$scratch"',
+ "done",
+ ].join("\n");
+};
+
+// Drops the ready marker so the next launch reinstalls the runtime from the
+// archive. Readiness is a presence check by design, so a cached tree whose
+// native payload is present but unloadable (truncated pty.node, a distro whose
+// glibc the binary needs and the tree was copied from another machine) stays
+// ready forever and fails the probe on every launch. Only the probe can see
+// that, so the probe is what revokes the marker. The tree itself is left in
+// place: the install script moves an unready root aside before extracting.
+export const buildWslRuntimeInvalidateScript = (runtimeId: string): string => {
+ const safeRuntimeId = sanitizeWslRuntimeId(runtimeId);
+ return [
+ "set -eu",
+ `rm -f "$HOME/.t3/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`,
+ ].join("\n");
+};
+
+export const parseWslRuntimeRoot = (stdout: string): string | null => {
+ const prefix = "runtimeRoot:";
+ const line = stdout.split("\n").find((candidate) => candidate.startsWith(prefix));
+ if (line === undefined) return null;
+ const runtimeRoot = line.slice(prefix.length).replace(/\r$/, "");
+ return runtimeRoot.startsWith("/") ? runtimeRoot : null;
+};
+
const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4;
export const formatNodePtyProbeFailureReason = (exitCode: number): string | null =>
@@ -390,23 +663,10 @@ export const formatMissingToolsReason = (
const ensureNodePtyImpl = (
distro: string | null,
- windowsRepoRoot: string,
- windowsToWslPath: (
- distro: string | null,
- windowsPath: string,
- ) => Effect.Effect>,
+ linuxRepoRoot: string,
options: EnsureWslNodePtyOptions = {},
): Effect.Effect =>
Effect.gen(function* () {
- const linuxRepoRootOption = yield* windowsToWslPath(distro, windowsRepoRoot);
- if (Option.isNone(linuxRepoRootOption)) {
- return {
- ok: false,
- reason: `wslpath conversion failed for ${windowsRepoRoot}`,
- fatal: false,
- } as const;
- }
- const linuxRepoRoot = linuxRepoRootOption.value;
// node-pty lives in the apps/server workspace's node_modules; resolve from
// there rather than the monorepo root, where Bun's hoist layout omits it.
const linuxServerDir = `${linuxRepoRoot}/apps/server`;
@@ -584,6 +844,96 @@ const ensureNodePtyImpl = (
} as const;
});
+const prepareWslRuntimeImpl = Effect.fn("desktop.wsl.prepareRuntimeImpl")(function* (
+ distro: string | null,
+ archive: WslRuntimeArchive,
+ windowsToWslPath: (
+ distro: string | null,
+ windowsPath: string,
+ ) => Effect.Effect>,
+): Effect.fn.Return {
+ const linuxArchivePath = yield* windowsToWslPath(distro, archive.windowsPath);
+ if (Option.isNone(linuxArchivePath)) {
+ return {
+ ok: false,
+ reason: `wslpath conversion failed for ${archive.windowsPath}`,
+ } as const;
+ }
+
+ const install = yield* runWslShell(
+ distro,
+ buildWslRuntimeInstallScript(linuxArchivePath.value, archive.runtimeId, archive.sha256),
+ RUNTIME_INSTALL_TIMEOUT,
+ { resolveNode: false },
+ );
+ if (install.transportFailure !== null) {
+ return {
+ ok: false,
+ reason:
+ install.transportFailure === "timeout"
+ ? "WSL runtime installation timed out. Check that the distro has free disk space, then retry."
+ : "WSL runtime installation lost communication with wsl.exe. Retry, or check that the distro is healthy.",
+ } as const;
+ }
+ if (install.exitCode !== 0) {
+ const trimmedTail = `${install.stdout}${install.stderr}`.trim().slice(-500);
+ return {
+ ok: false,
+ reason: `WSL runtime installation failed (exit ${install.exitCode}): ${trimmedTail || "no stderr captured"}`,
+ } as const;
+ }
+
+ const linuxAppRoot = parseWslRuntimeRoot(install.stdout);
+ return linuxAppRoot === null
+ ? {
+ ok: false,
+ reason: "WSL runtime installation completed without reporting its cache path.",
+ }
+ : { ok: true, linuxAppRoot };
+});
+
+const pruneWslRuntimesImpl = Effect.fn("desktop.wsl.pruneRuntimesImpl")(function* (
+ distro: string | null,
+ runtimeId: string,
+): Effect.fn.Return {
+ const result = yield* runWslShell(
+ distro,
+ buildWslRuntimePruneScript(runtimeId),
+ RUNTIME_PRUNE_TIMEOUT,
+ { resolveNode: false },
+ );
+ if (result.transportFailure === null && result.exitCode === 0) return;
+
+ const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
+ yield* Effect.logWarning("Could not prune old WSL runtime caches.", {
+ distro,
+ runtimeId,
+ detail: detail || `exit ${result.exitCode}`,
+ });
+});
+
+const invalidateWslRuntimeImpl = Effect.fn("desktop.wsl.invalidateRuntimeImpl")(function* (
+ distro: string | null,
+ runtimeId: string,
+): Effect.fn.Return {
+ const result = yield* runWslShell(
+ distro,
+ buildWslRuntimeInvalidateScript(runtimeId),
+ RUNTIME_INVALIDATE_TIMEOUT,
+ { resolveNode: false },
+ );
+ if (result.transportFailure === null && result.exitCode === 0) return;
+
+ const detail = `${result.stdout}${result.stderr}`.trim().slice(-500);
+ // Best effort: the caller has already fallen back to the mounted tree, so a
+ // failure here only costs the reinstall that would have repaired the cache.
+ yield* Effect.logWarning("Could not invalidate the staged WSL runtime cache.", {
+ distro,
+ runtimeId,
+ detail: detail || `exit ${result.exitCode}`,
+ });
+});
+
export const probeWslDistros: Effect.Effect<
readonly WslDistro[],
DesktopWslDistroListError,
@@ -778,9 +1128,15 @@ export interface DesktopWslEnvironmentTestStub {
readonly windowsToWslPath?: (distro: string | null, windowsPath: string) => Option.Option;
readonly getUserHome?: (distro: string | null) => Option.Option;
readonly getDistroIp?: (distro: string | null) => Option.Option;
+ readonly prepareRuntime?: (
+ distro: string | null,
+ archive: WslRuntimeArchive,
+ ) => PrepareWslRuntimeResult;
+ readonly pruneRuntimes?: (distro: string | null, runtimeId: string) => Effect.Effect;
+ readonly invalidateRuntime?: (distro: string | null, runtimeId: string) => Effect.Effect;
readonly ensureNodePty?: (
distro: string | null,
- windowsRepoRoot: string,
+ linuxAppRoot: string,
options?: EnsureWslNodePtyOptions,
) => EnsureWslNodePtyResult;
}
@@ -800,9 +1156,19 @@ export const layerTest = (stub: DesktopWslEnvironmentTestStub = {}) => {
Effect.succeed(stub.windowsToWslPath?.(distro, windowsPath) ?? Option.none()),
getUserHome: (distro) => Effect.succeed(stub.getUserHome?.(distro) ?? Option.none()),
getDistroIp: (distro) => Effect.succeed(stub.getDistroIp?.(distro) ?? Option.none()),
- ensureNodePty: (distro, windowsRepoRoot, options) =>
+ prepareRuntime: (distro, archive) =>
+ Effect.succeed(
+ stub.prepareRuntime?.(distro, archive) ?? {
+ ok: false,
+ reason: "prepareRuntime stub not configured",
+ },
+ ),
+ pruneRuntimes: (distro, runtimeId) => stub.pruneRuntimes?.(distro, runtimeId) ?? Effect.void,
+ invalidateRuntime: (distro, runtimeId) =>
+ stub.invalidateRuntime?.(distro, runtimeId) ?? Effect.void,
+ ensureNodePty: (distro, linuxAppRoot, options) =>
Effect.succeed(
- stub.ensureNodePty?.(distro, windowsRepoRoot, options) ?? {
+ stub.ensureNodePty?.(distro, linuxAppRoot, options) ?? {
ok: false,
reason: "ensureNodePty stub not configured",
fatal: true,
@@ -881,8 +1247,20 @@ export const layer = Layer.effect(
windowsToWslPath,
getUserHome,
getDistroIp,
- ensureNodePty: (distro, windowsRepoRoot, options) =>
- provideSpawner(ensureNodePtyImpl(distro, windowsRepoRoot, windowsToWslPath, options)).pipe(
+ prepareRuntime: (distro, archive) =>
+ provideSpawner(prepareWslRuntimeImpl(distro, archive, windowsToWslPath)).pipe(
+ Effect.withSpan("desktop.wsl.prepareRuntime"),
+ ),
+ pruneRuntimes: (distro, runtimeId) =>
+ provideSpawner(pruneWslRuntimesImpl(distro, runtimeId)).pipe(
+ Effect.withSpan("desktop.wsl.pruneRuntimes"),
+ ),
+ invalidateRuntime: (distro, runtimeId) =>
+ provideSpawner(invalidateWslRuntimeImpl(distro, runtimeId)).pipe(
+ Effect.withSpan("desktop.wsl.invalidateRuntime"),
+ ),
+ ensureNodePty: (distro, linuxAppRoot, options) =>
+ provideSpawner(ensureNodePtyImpl(distro, linuxAppRoot, options)).pipe(
Effect.withSpan("desktop.wsl.ensureNodePty"),
),
});
diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts
index 8c1a5b020b..0c02080616 100644
--- a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts
+++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts
@@ -297,6 +297,93 @@ describe("DesktopWslServerTree", () => {
).pipe(Effect.provide(NodeServices.layer)),
);
+ it.effect("removes the legacy Windows extraction tree without preparing a fallback", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const resourcesPath = path.join(tempDir, "resources");
+ const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree");
+ yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3"), { recursive: true });
+ yield* fileSystem.writeFileString(path.join(treeRoot, "1.2.3", "legacy"), "old");
+
+ yield* Effect.gen(function* () {
+ const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
+ yield* tree.cleanupLegacy;
+ }).pipe(
+ Effect.provide(
+ DesktopWslServerTree.layer.pipe(
+ Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })),
+ ),
+ ),
+ );
+
+ assert.isFalse(yield* fileSystem.exists(treeRoot));
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("re-extracts after legacy cleanup partially deletes the completed tree", () =>
+ withTempDir((tempDir) =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const resourcesPath = path.join(tempDir, "resources");
+ const serverRoot = path.join(resourcesPath, "server.asar");
+ const sourceEntryPath = path.join(serverRoot, "apps/server/dist/bin.mjs");
+ yield* fileSystem.makeDirectory(path.dirname(sourceEntryPath), { recursive: true });
+ yield* fileSystem.writeFileString(sourceEntryPath, "fresh-server-entry");
+
+ const initial = yield* ensureWith({ baseDir: tempDir, resourcesPath });
+ assert.isTrue(initial.ok);
+ const versionDir = initial.ok ? initial.root : "";
+ const treeRoot = path.dirname(versionDir);
+ const extractedEntryPath = path.join(versionDir, "apps/server/dist/bin.mjs");
+ let cleanupFailed = false;
+ const partialCleanupFileSystem = Layer.effect(
+ FileSystem.FileSystem,
+ Effect.gen(function* () {
+ const realFileSystem = yield* FileSystem.FileSystem;
+ return {
+ ...realFileSystem,
+ remove: (target, options) =>
+ String(target) === treeRoot && options?.recursive === true && !cleanupFailed
+ ? Effect.gen(function* () {
+ cleanupFailed = true;
+ yield* realFileSystem.remove(extractedEntryPath);
+ return yield* PlatformError.systemError({
+ _tag: "PermissionDenied",
+ module: "FileSystem",
+ method: "remove",
+ pathOrDescriptor: treeRoot,
+ description: "simulated partial legacy cleanup",
+ });
+ })
+ : realFileSystem.remove(target, options),
+ } satisfies FileSystem.FileSystem;
+ }),
+ ).pipe(Layer.provide(NodeServices.layer));
+
+ const result = yield* Effect.gen(function* () {
+ const tree = yield* DesktopWslServerTree.DesktopWslServerTree;
+ yield* tree.cleanupLegacy;
+ return yield* tree.ensure;
+ }).pipe(
+ Effect.provide(
+ DesktopWslServerTree.layer.pipe(
+ Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })),
+ Layer.provideMerge(partialCleanupFileSystem),
+ ),
+ ),
+ );
+
+ assert.isTrue(cleanupFailed);
+ assert.isTrue(result.ok);
+ assert.equal(yield* fileSystem.readFileString(extractedEntryPath), "fresh-server-entry");
+ }),
+ ).pipe(Effect.provide(NodeServices.layer)),
+ );
+
it.effect("reports a retryable failure when the archive cannot be read", () =>
withTempDir((tempDir) =>
Effect.gen(function* () {
diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts
index 0b87f7bf1f..c6094e780c 100644
--- a/apps/desktop/src/wsl/DesktopWslServerTree.ts
+++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts
@@ -11,10 +11,9 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
// Packaged Windows builds ship the server tree inside resources/server.asar
// (see scripts/build-desktop-artifact.ts). The Windows primary reads it in
// place through the asar-aware ELECTRON_RUN_AS_NODE runtime, but the WSL
-// backend launches plain `wsl.exe -- node`, which cannot read an asar
-// archive. This service materializes the archive into a real, version-keyed
-// directory the first time the WSL backend starts, and reuses it afterwards —
-// so only users who enable WSL ever pay for a loose copy of the server tree.
+// backend launches plain `wsl.exe -- node`, which cannot read an asar archive.
+// This fallback service materializes the archive into a real, version-keyed
+// directory only when the distro-local runtime cannot be prepared.
//
// Reading through Electron's patched fs also transparently returns the
// contents of files that electron-builder/asar left in the server.asar.unpacked
@@ -52,6 +51,10 @@ export class DesktopWslServerTree extends Context.Service<
// the checkout already is that directory; packaged Windows builds extract
// server.asar on first use.
readonly ensure: Effect.Effect;
+ // Removes the Windows-side extraction cache after a distro-local runtime
+ // has proven healthy. Serialized with ensure so cleanup cannot race an
+ // extraction that the mounted fallback is preparing.
+ readonly cleanupLegacy: Effect.Effect;
}
>()("@t3tools/desktop/wsl/DesktopWslServerTree") {}
@@ -173,6 +176,28 @@ export const make = Effect.gen(function* () {
// first caller extracts, later callers see the marker and reuse the tree.
const gate = yield* Semaphore.make(1);
+ const cleanupLegacy = gate
+ .withPermits(1)(
+ needsExtraction
+ ? Effect.gen(function* () {
+ // Invalidate completeness before recursive deletion. Windows can
+ // remove part of a tree and then fail on a locked file; without
+ // this ordering, a surviving marker makes ensure reuse that
+ // half-deleted fallback instead of extracting it again.
+ yield* fs.remove(join(versionDir, MARKER_FILE_NAME), { force: true });
+ yield* fs.remove(treeRoot, { recursive: true, force: true });
+ }).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("[wsl-server-tree] Could not remove the legacy extraction cache.", {
+ treeRoot,
+ cause,
+ }),
+ ),
+ )
+ : Effect.void,
+ )
+ .pipe(Effect.withSpan("desktop.wslServerTree.cleanupLegacy"));
+
const ensure: Effect.Effect = gate
.withPermits(1)(
Effect.gen(function* () {
@@ -205,13 +230,14 @@ export const make = Effect.gen(function* () {
)
.pipe(Effect.withSpan("desktop.wslServerTree.ensure"));
- return DesktopWslServerTree.of({ ensure });
+ return DesktopWslServerTree.of({ ensure, cleanupLegacy });
});
export const layer = Layer.effect(DesktopWslServerTree, make);
export interface DesktopWslServerTreeTestStub {
readonly result?: WslServerTreeResult;
+ readonly cleanupLegacy?: Effect.Effect;
}
export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
@@ -221,6 +247,7 @@ export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) =>
const environment = yield* DesktopEnvironment.DesktopEnvironment;
return DesktopWslServerTree.of({
ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }),
+ cleanupLegacy: stub.cleanupLegacy ?? Effect.void,
});
}),
);
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
index 9a51725478..0dc8dca968 100644
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -15,6 +15,9 @@ const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?
const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
const fromRepoRoot = (relativePath: string) => `../../${relativePath}`;
+// Universal exports already contain their own rounded-square silhouette. Using one as an adaptive
+// foreground makes Android draw an icon shape inside the launcher's mask.
+const androidAdaptiveForeground = "./assets/android-icon-foreground.png";
if (
isIosPersonalTeamBuild &&
@@ -30,7 +33,7 @@ const DEVELOPMENT_ASSETS = {
appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject),
splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng),
- androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng),
+ androidAdaptiveForeground,
androidAdaptiveBackgroundColor: "#00639B",
androidMonochromeIcon: "./assets/android-icon-mark.png",
androidNotificationIcon: "./assets/android-notification-icon.png",
@@ -41,7 +44,7 @@ const PREVIEW_ASSETS = {
appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject),
splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng),
- androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng),
+ androidAdaptiveForeground,
androidAdaptiveBackgroundColor: "#111533",
androidMonochromeIcon: "./assets/android-icon-mark.png",
androidNotificationIcon: "./assets/android-notification-icon.png",
@@ -52,7 +55,7 @@ const RELEASE_ASSETS = {
appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject),
splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng),
- androidAdaptiveForeground: "./assets/android-icon-mark.png",
+ androidAdaptiveForeground,
androidAdaptiveBackgroundColor: "#000000",
androidMonochromeIcon: "./assets/android-icon-mark.png",
androidNotificationIcon: "./assets/android-notification-icon.png",
diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png
new file mode 100644
index 0000000000..4f4374c7eb
Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ
diff --git a/apps/mobile/assets/android-icon-foreground.svg b/apps/mobile/assets/android-icon-foreground.svg
new file mode 100644
index 0000000000..8d974992ab
--- /dev/null
+++ b/apps/mobile/assets/android-icon-foreground.svg
@@ -0,0 +1,8 @@
+
diff --git a/apps/mobile/src/lib/mobileDefaultTheme.ts b/apps/mobile/generated-uniwind-default-theme-variables.json
similarity index 93%
rename from apps/mobile/src/lib/mobileDefaultTheme.ts
rename to apps/mobile/generated-uniwind-default-theme-variables.json
index 66afae4647..427d370acb 100644
--- a/apps/mobile/src/lib/mobileDefaultTheme.ts
+++ b/apps/mobile/generated-uniwind-default-theme-variables.json
@@ -1,8 +1,5 @@
-import type { MobileThemeVariables } from "./mobileTheme";
-
-/** The existing T3 Code mobile palette, retained as the upgrade-safe default. */
-export const DEFAULT_MOBILE_THEME_VARIABLES = {
- light: {
+{
+ "light": {
"--color-screen": "#f2f2f7",
"--color-sheet": "rgba(242, 242, 247, 0.98)",
"--color-sheet-solid": "#f2f2f7",
@@ -67,9 +64,9 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
"--color-drawer-shadow": "rgba(0, 0, 0, 0.12)",
"--color-dot-separator": "rgba(0, 0, 0, 0.2)",
"--color-wordmark": "#262626",
- "--color-chevron": "rgba(0, 0, 0, 0.2)",
+ "--color-chevron": "rgba(0, 0, 0, 0.2)"
},
- dark: {
+ "dark": {
"--color-screen": "#0a0a0a",
"--color-sheet": "rgba(14, 14, 14, 0.98)",
"--color-sheet-solid": "#0e0e0e",
@@ -134,6 +131,6 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
"--color-drawer-shadow": "rgba(0, 0, 0, 0.32)",
"--color-dot-separator": "rgba(255, 255, 255, 0.2)",
"--color-wordmark": "#f5f5f5",
- "--color-chevron": "rgba(255, 255, 255, 0.2)",
- },
-} as const satisfies Readonly>;
+ "--color-chevron": "rgba(255, 255, 255, 0.2)"
+ }
+}
diff --git a/apps/mobile/generated-uniwind-theme-names.json b/apps/mobile/generated-uniwind-theme-names.json
new file mode 100644
index 0000000000..4ec9f01f8e
--- /dev/null
+++ b/apps/mobile/generated-uniwind-theme-names.json
@@ -0,0 +1,12 @@
+[
+ "t3-chat-light",
+ "t3-chat-dark",
+ "grove-light",
+ "grove-dark",
+ "ocean-light",
+ "ocean-dark",
+ "ember-light",
+ "ember-dark",
+ "iris-light",
+ "iris-dark"
+]
diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css
new file mode 100644
index 0000000000..7f8f9c16af
--- /dev/null
+++ b/apps/mobile/generated-uniwind-themes.css
@@ -0,0 +1,1374 @@
+/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */
+@layer theme {
+ :root {
+ @variant light {
+ --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
+ --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
+ --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%);
+ --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815);
+ --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
+ --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
+ --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%);
+ --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966);
+ --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023);
+ --color-adaptive-neutral-100-900: oklch(97% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-800: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%);
+ --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%);
+ --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%);
+ --color-adaptive-neutral-400-500: oklch(70.8% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%);
+ --color-adaptive-neutral-500-400: oklch(55.6% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(43.9% 0 0);
+ --color-adaptive-neutral-600-400: oklch(43.9% 0 0);
+ --color-adaptive-neutral-950-50: oklch(14.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
+ --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
+ --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
+ --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518);
+ --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58);
+ --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439);
+ --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
+ --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
+ --color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
+ --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009);
+ --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581);
+ --color-adaptive-white-neutral-950-a70: #fff;
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%);
+ --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938);
+ --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786);
+ }
+
+ @variant dark {
+ --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
+ --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
+ --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
+ --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
+ --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%);
+ --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
+ --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
+ --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%);
+ --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-neutral-100-900: oklch(20.5% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%);
+ --color-adaptive-neutral-200-800: oklch(26.9% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%);
+ --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%);
+ --color-adaptive-neutral-400-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%);
+ --color-adaptive-neutral-500-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(87% 0 0);
+ --color-adaptive-neutral-600-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-950-50: oklch(98.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
+ --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
+ --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
+ --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571);
+ --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%);
+ --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%);
+ --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
+ --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
+ --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541);
+ --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571);
+ --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%);
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%);
+ --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067);
+ --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286);
+ }
+
+ @variant t3-chat-light {
+ --color-screen: #fdf7fd;
+ --color-sheet: rgba(253, 247, 253, 0.98);
+ --color-sheet-solid: #fdf7fd;
+ --color-card: #fdfafd;
+ --color-card-alt: #faf3fb;
+ --color-card-translucent: rgba(253, 250, 253, 0.8);
+ --color-foreground: #501854;
+ --color-foreground-secondary: #ac1668;
+ --color-foreground-muted: #8d1255;
+ --color-foreground-tertiary: #ac1668;
+ --color-border: #eee1ed;
+ --color-border-subtle: rgba(238, 225, 237, 0.7);
+ --color-separator: rgba(238, 225, 237, 0.55);
+ --color-subtle: #eaa7cb;
+ --color-subtle-strong: #f1c4e6;
+ --color-inline-skill-background: #f3e6f5;
+ --color-inline-skill-border: rgba(219, 39, 119, 0.42);
+ --color-inline-skill-foreground: #454554;
+ --color-primary: #db2777;
+ --color-primary-foreground: #ffffff;
+ --color-primary-shadow: #000000;
+ --color-secondary: #f1c4e6;
+ --color-secondary-foreground: #77347c;
+ --color-secondary-border: #eee1ed;
+ --color-switch-active-track: #db2777;
+ --color-switch-active-thumb: #ffffff;
+ --color-switch-inactive-track: #f1c4e6;
+ --color-switch-inactive-thumb: #8d1255;
+ --color-danger: #fde4f1;
+ --color-danger-border: rgba(247, 8, 108, 0.32);
+ --color-danger-foreground: #9d174d;
+ --color-input: #fdfafd;
+ --color-input-border: #e7c1dc;
+ --color-sidebar-search: #f8f8f7;
+ --color-placeholder: #8b5f90;
+ --color-icon: #501854;
+ --color-icon-muted: #ac1668;
+ --color-icon-subtle: #ac1668;
+ --color-header: rgba(253, 247, 253, 0.97);
+ --color-header-border: #efbdeb;
+ --color-glass-surface: rgba(255, 255, 255, 0.74);
+ --color-glass-tint: rgba(255, 255, 255, 0.22);
+ --color-status-bar: #fdf7fd;
+ --color-md-body: #501854;
+ --color-md-strong: #501854;
+ --color-md-link: #db2777;
+ --color-md-blockquote-border: #eee1ed;
+ --color-md-blockquote-bg: #eaa7cb;
+ --color-md-code-bg: #f5ecf9;
+ --color-md-code-text: #673c8b;
+ --color-md-user-code-bg: rgba(73, 44, 97, 0.18);
+ --color-md-user-code-text: #492c61;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
+ --color-md-user-fence-text: #492c61;
+ --color-md-hr: #eee1ed;
+ --color-user-bubble: #f7def2;
+ --color-user-bubble-foreground: #492c61;
+ --color-user-bubble-foreground-muted: rgba(73, 44, 97, 0.78);
+ --color-user-bubble-skill-foreground: #c12269;
+ --color-backdrop: rgba(0, 0, 0, 0.22);
+ --color-drawer: rgba(242, 225, 244, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.12);
+ --color-dot-separator: rgba(172, 22, 104, 0.35);
+ --color-wordmark: #501854;
+ --color-chevron: rgba(172, 22, 104, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
+ --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
+ --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%);
+ --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815);
+ --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
+ --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
+ --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%);
+ --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966);
+ --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023);
+ --color-adaptive-neutral-100-900: oklch(97% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-800: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%);
+ --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%);
+ --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%);
+ --color-adaptive-neutral-400-500: oklch(70.8% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%);
+ --color-adaptive-neutral-500-400: oklch(55.6% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(43.9% 0 0);
+ --color-adaptive-neutral-600-400: oklch(43.9% 0 0);
+ --color-adaptive-neutral-950-50: oklch(14.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
+ --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
+ --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
+ --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518);
+ --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58);
+ --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439);
+ --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
+ --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
+ --color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
+ --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009);
+ --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581);
+ --color-adaptive-white-neutral-950-a70: #fff;
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%);
+ --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938);
+ --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786);
+ }
+
+ @variant t3-chat-dark {
+ --color-screen: #1f1a24;
+ --color-sheet: rgba(31, 26, 36, 0.98);
+ --color-sheet-solid: #1f1a24;
+ --color-card: #2c2631;
+ --color-card-alt: #29232d;
+ --color-card-translucent: rgba(44, 38, 49, 0.8);
+ --color-foreground: #f9f8fb;
+ --color-foreground-secondary: #e7d0dd;
+ --color-foreground-muted: #e7d0dd;
+ --color-foreground-tertiary: #e7d0dd;
+ --color-border: #27242c;
+ --color-border-subtle: rgba(39, 36, 44, 0.7);
+ --color-separator: rgba(39, 36, 44, 0.55);
+ --color-subtle: #423a45;
+ --color-subtle-strong: #362d3d;
+ --color-inline-skill-background: #463753;
+ --color-inline-skill-border: rgba(163, 0, 76, 0.42);
+ --color-inline-skill-foreground: #f8f1f5;
+ --color-primary: #a3004c;
+ --color-primary-foreground: #fbd0e8;
+ --color-primary-shadow: #000000;
+ --color-secondary: #362d3d;
+ --color-secondary-foreground: #d4c7e1;
+ --color-secondary-border: #27242c;
+ --color-switch-active-track: #a3004c;
+ --color-switch-active-thumb: #fbd0e8;
+ --color-switch-inactive-track: #362d3d;
+ --color-switch-inactive-thumb: #e7d0dd;
+ --color-danger: #331a2b;
+ --color-danger-border: rgba(157, 23, 77, 0.32);
+ --color-danger-foreground: #fbd0e8;
+ --color-input: #2c2631;
+ --color-input-border: #302029;
+ --color-sidebar-search: #261922;
+ --color-placeholder: #968d9f;
+ --color-icon: #f9f8fb;
+ --color-icon-muted: #d4c7e1;
+ --color-icon-subtle: #e7d0dd;
+ --color-header: rgba(31, 26, 36, 0.97);
+ --color-header-border: #27242c;
+ --color-glass-surface: rgba(16, 10, 14, 0.74);
+ --color-glass-tint: rgba(16, 10, 14, 0.22);
+ --color-status-bar: #1f1a24;
+ --color-md-body: #f9f8fb;
+ --color-md-strong: #f9f8fb;
+ --color-md-link: #a3004c;
+ --color-md-blockquote-border: #27242c;
+ --color-md-blockquote-bg: #423a45;
+ --color-md-code-bg: #1f1a24;
+ --color-md-code-text: #d8c3ef;
+ --color-md-user-code-bg: rgba(242, 235, 250, 0.18);
+ --color-md-user-code-text: #f2ebfa;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
+ --color-md-user-fence-text: #f2ebfa;
+ --color-md-hr: #27242c;
+ --color-user-bubble: #2b2431;
+ --color-user-bubble-foreground: #f2ebfa;
+ --color-user-bubble-foreground-muted: rgba(242, 235, 250, 0.78);
+ --color-user-bubble-skill-foreground: #cb709a;
+ --color-backdrop: rgba(0, 0, 0, 0.48);
+ --color-drawer: rgba(23, 16, 24, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.32);
+ --color-dot-separator: rgba(231, 208, 221, 0.35);
+ --color-wordmark: #f9f8fb;
+ --color-chevron: rgba(231, 208, 221, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
+ --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
+ --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
+ --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
+ --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%);
+ --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
+ --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
+ --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%);
+ --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-neutral-100-900: oklch(20.5% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%);
+ --color-adaptive-neutral-200-800: oklch(26.9% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%);
+ --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%);
+ --color-adaptive-neutral-400-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%);
+ --color-adaptive-neutral-500-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(87% 0 0);
+ --color-adaptive-neutral-600-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-950-50: oklch(98.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
+ --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
+ --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
+ --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571);
+ --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%);
+ --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%);
+ --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
+ --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
+ --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541);
+ --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571);
+ --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%);
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%);
+ --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067);
+ --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286);
+ }
+
+ @variant grove-light {
+ --color-screen: #f3f7f4;
+ --color-sheet: rgba(243, 247, 244, 0.98);
+ --color-sheet-solid: #f3f7f4;
+ --color-card: #ecefed;
+ --color-card-alt: #f3f7f4;
+ --color-card-translucent: rgba(236, 239, 237, 0.8);
+ --color-foreground: #241523;
+ --color-foreground-secondary: #746c73;
+ --color-foreground-muted: #6e696f;
+ --color-foreground-tertiary: #746c73;
+ --color-border: #cbd5d1;
+ --color-border-subtle: rgba(203, 213, 209, 0.7);
+ --color-separator: rgba(203, 213, 209, 0.55);
+ --color-subtle: #e6f0ea;
+ --color-subtle-strong: #e2ede7;
+ --color-inline-skill-background: #d5e6dd;
+ --color-inline-skill-border: rgba(27, 125, 80, 0.42);
+ --color-inline-skill-foreground: #241523;
+ --color-primary: #1b7d50;
+ --color-primary-foreground: #fffaff;
+ --color-primary-shadow: #000000;
+ --color-secondary: #e2ede7;
+ --color-secondary-foreground: #241523;
+ --color-secondary-border: #cbd5d1;
+ --color-switch-active-track: #1b7d50;
+ --color-switch-active-thumb: #fffaff;
+ --color-switch-inactive-track: #e2ede7;
+ --color-switch-inactive-thumb: #6e696f;
+ --color-danger: #f4e7e5;
+ --color-danger-border: rgba(251, 44, 54, 0.32);
+ --color-danger-foreground: #c10007;
+ --color-input: #ecefed;
+ --color-input-border: #becbc5;
+ --color-sidebar-search: #d3dcd8;
+ --color-placeholder: #716971;
+ --color-icon: #241523;
+ --color-icon-muted: #746c73;
+ --color-icon-subtle: #746c73;
+ --color-header: rgba(243, 247, 244, 0.97);
+ --color-header-border: #d5e6dd;
+ --color-glass-surface: rgba(231, 233, 232, 0.74);
+ --color-glass-tint: rgba(231, 233, 232, 0.22);
+ --color-status-bar: #f3f7f4;
+ --color-md-body: #241523;
+ --color-md-strong: #241523;
+ --color-md-link: #1b7d50;
+ --color-md-blockquote-border: #cbd5d1;
+ --color-md-blockquote-bg: #e6f0ea;
+ --color-md-code-bg: #eef1ef;
+ --color-md-code-text: #241523;
+ --color-md-user-code-bg: rgba(36, 21, 35, 0.18);
+ --color-md-user-code-text: #241523;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
+ --color-md-user-fence-text: #241523;
+ --color-md-hr: #cbd5d1;
+ --color-user-bubble: #cce1d7;
+ --color-user-bubble-foreground: #241523;
+ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78);
+ --color-user-bubble-skill-foreground: #815a0e;
+ --color-backdrop: rgba(0, 0, 0, 0.22);
+ --color-drawer: rgba(226, 237, 231, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.12);
+ --color-dot-separator: rgba(116, 108, 115, 0.35);
+ --color-wordmark: #241523;
+ --color-chevron: rgba(116, 108, 115, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
+ --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
+ --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%);
+ --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815);
+ --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
+ --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
+ --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%);
+ --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966);
+ --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023);
+ --color-adaptive-neutral-100-900: oklch(97% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-800: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%);
+ --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%);
+ --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%);
+ --color-adaptive-neutral-400-500: oklch(70.8% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%);
+ --color-adaptive-neutral-500-400: oklch(55.6% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(43.9% 0 0);
+ --color-adaptive-neutral-600-400: oklch(43.9% 0 0);
+ --color-adaptive-neutral-950-50: oklch(14.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
+ --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
+ --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
+ --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518);
+ --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58);
+ --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439);
+ --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
+ --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
+ --color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
+ --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009);
+ --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581);
+ --color-adaptive-white-neutral-950-a70: #fff;
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%);
+ --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938);
+ --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786);
+ }
+
+ @variant grove-dark {
+ --color-screen: #1b2821;
+ --color-sheet: rgba(27, 40, 33, 0.98);
+ --color-sheet-solid: #1b2821;
+ --color-card: #36413c;
+ --color-card-alt: #1b2821;
+ --color-card-translucent: rgba(54, 65, 60, 0.8);
+ --color-foreground: #fffaff;
+ --color-foreground-secondary: #919595;
+ --color-foreground-muted: #9da5a2;
+ --color-foreground-tertiary: #919595;
+ --color-border: #415f4f;
+ --color-border-subtle: rgba(65, 95, 79, 0.7);
+ --color-separator: rgba(65, 95, 79, 0.55);
+ --color-subtle: #253e31;
+ --color-subtle-strong: #2a4b39;
+ --color-inline-skill-background: #325c46;
+ --color-inline-skill-border: rgba(105, 214, 154, 0.42);
+ --color-inline-skill-foreground: #fffaff;
+ --color-primary: #69d69a;
+ --color-primary-foreground: #241523;
+ --color-primary-shadow: #000000;
+ --color-secondary: #2a4b39;
+ --color-secondary-foreground: #fffaff;
+ --color-secondary-border: #415f4f;
+ --color-switch-active-track: #69d69a;
+ --color-switch-active-thumb: #241523;
+ --color-switch-inactive-track: #2a4b39;
+ --color-switch-inactive-thumb: #9da5a2;
+ --color-danger: #3f2c28;
+ --color-danger-border: rgba(251, 65, 74, 0.32);
+ --color-danger-foreground: #ff6668;
+ --color-input: #36413c;
+ --color-input-border: #4f725f;
+ --color-sidebar-search: #45554d;
+ --color-placeholder: #a9abab;
+ --color-icon: #fffaff;
+ --color-icon-muted: #919595;
+ --color-icon-subtle: #919595;
+ --color-header: rgba(27, 40, 33, 0.97);
+ --color-header-border: #36654c;
+ --color-glass-surface: rgba(68, 77, 73, 0.74);
+ --color-glass-tint: rgba(68, 77, 73, 0.22);
+ --color-status-bar: #1b2821;
+ --color-md-body: #fffaff;
+ --color-md-strong: #fffaff;
+ --color-md-link: #69d69a;
+ --color-md-blockquote-border: #415f4f;
+ --color-md-blockquote-bg: #253e31;
+ --color-md-code-bg: #28342e;
+ --color-md-code-text: #fffaff;
+ --color-md-user-code-bg: rgba(255, 250, 255, 0.18);
+ --color-md-user-code-text: #fffaff;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
+ --color-md-user-fence-text: #fffaff;
+ --color-md-hr: #415f4f;
+ --color-user-bubble: #37664d;
+ --color-user-bubble-foreground: #fffaff;
+ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78);
+ --color-user-bubble-skill-foreground: #eed295;
+ --color-backdrop: rgba(0, 0, 0, 0.48);
+ --color-drawer: rgba(33, 54, 43, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.32);
+ --color-dot-separator: rgba(145, 149, 149, 0.35);
+ --color-wordmark: #fffaff;
+ --color-chevron: rgba(145, 149, 149, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
+ --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
+ --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
+ --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
+ --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%);
+ --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
+ --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
+ --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%);
+ --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-neutral-100-900: oklch(20.5% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%);
+ --color-adaptive-neutral-200-800: oklch(26.9% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%);
+ --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%);
+ --color-adaptive-neutral-400-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%);
+ --color-adaptive-neutral-500-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(87% 0 0);
+ --color-adaptive-neutral-600-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-950-50: oklch(98.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
+ --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
+ --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
+ --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571);
+ --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%);
+ --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%);
+ --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
+ --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
+ --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541);
+ --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571);
+ --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%);
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%);
+ --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067);
+ --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286);
+ }
+
+ @variant ocean-light {
+ --color-screen: #f5f7f8;
+ --color-sheet: rgba(245, 247, 248, 0.98);
+ --color-sheet-solid: #f5f7f8;
+ --color-card: #edeff1;
+ --color-card-alt: #f5f7f8;
+ --color-card-translucent: rgba(237, 239, 241, 0.8);
+ --color-foreground: #241523;
+ --color-foreground-secondary: #746c75;
+ --color-foreground-muted: #6f6873;
+ --color-foreground-tertiary: #746c75;
+ --color-border: #cdd4dc;
+ --color-border-subtle: rgba(205, 212, 220, 0.7);
+ --color-separator: rgba(205, 212, 220, 0.55);
+ --color-subtle: #e8eff4;
+ --color-subtle-strong: #e4ecf2;
+ --color-inline-skill-background: #d8e4ee;
+ --color-inline-skill-border: rgba(38, 114, 175, 0.42);
+ --color-inline-skill-foreground: #241523;
+ --color-primary: #2672af;
+ --color-primary-foreground: #fffaff;
+ --color-primary-shadow: #000000;
+ --color-secondary: #e4ecf2;
+ --color-secondary-foreground: #241523;
+ --color-secondary-border: #cdd4dc;
+ --color-switch-active-track: #2672af;
+ --color-switch-active-thumb: #fffaff;
+ --color-switch-inactive-track: #e4ecf2;
+ --color-switch-inactive-thumb: #6f6873;
+ --color-danger: #f5e6e9;
+ --color-danger-border: rgba(251, 44, 54, 0.32);
+ --color-danger-foreground: #c10007;
+ --color-input: #edeff1;
+ --color-input-border: #c0c9d4;
+ --color-sidebar-search: #d5dbe2;
+ --color-placeholder: #716972;
+ --color-icon: #241523;
+ --color-icon-muted: #746c75;
+ --color-icon-subtle: #746c75;
+ --color-header: rgba(245, 247, 248, 0.97);
+ --color-header-border: #d8e4ee;
+ --color-glass-surface: rgba(232, 233, 235, 0.74);
+ --color-glass-tint: rgba(232, 233, 235, 0.22);
+ --color-status-bar: #f5f7f8;
+ --color-md-body: #241523;
+ --color-md-strong: #241523;
+ --color-md-link: #2672af;
+ --color-md-blockquote-border: #cdd4dc;
+ --color-md-blockquote-bg: #e8eff4;
+ --color-md-code-bg: #f0f1f3;
+ --color-md-code-text: #241523;
+ --color-md-user-code-bg: rgba(36, 21, 35, 0.18);
+ --color-md-user-code-text: #241523;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
+ --color-md-user-fence-text: #241523;
+ --color-md-hr: #cdd4dc;
+ --color-user-bubble: #d0dfeb;
+ --color-user-bubble-foreground: #241523;
+ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78);
+ --color-user-bubble-skill-foreground: #0a6c72;
+ --color-backdrop: rgba(0, 0, 0, 0.22);
+ --color-drawer: rgba(228, 236, 242, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.12);
+ --color-dot-separator: rgba(116, 108, 117, 0.35);
+ --color-wordmark: #241523;
+ --color-chevron: rgba(116, 108, 117, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
+ --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
+ --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%);
+ --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815);
+ --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
+ --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
+ --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%);
+ --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966);
+ --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023);
+ --color-adaptive-neutral-100-900: oklch(97% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-800: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%);
+ --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%);
+ --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%);
+ --color-adaptive-neutral-400-500: oklch(70.8% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%);
+ --color-adaptive-neutral-500-400: oklch(55.6% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(43.9% 0 0);
+ --color-adaptive-neutral-600-400: oklch(43.9% 0 0);
+ --color-adaptive-neutral-950-50: oklch(14.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
+ --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
+ --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
+ --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518);
+ --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58);
+ --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439);
+ --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
+ --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
+ --color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
+ --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009);
+ --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581);
+ --color-adaptive-white-neutral-950-a70: #fff;
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%);
+ --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938);
+ --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786);
+ }
+
+ @variant ocean-dark {
+ --color-screen: #17212b;
+ --color-sheet: rgba(23, 33, 43, 0.98);
+ --color-sheet-solid: #17212b;
+ --color-card: #333b45;
+ --color-card-alt: #17212b;
+ --color-card-translucent: rgba(51, 59, 69, 0.8);
+ --color-foreground: #fffaff;
+ --color-foreground-secondary: #8d8f97;
+ --color-foreground-muted: #969ca6;
+ --color-foreground-tertiary: #8d8f97;
+ --color-border: #405567;
+ --color-border-subtle: rgba(64, 85, 103, 0.7);
+ --color-separator: rgba(64, 85, 103, 0.55);
+ --color-subtle: #233544;
+ --color-subtle-strong: #293f52;
+ --color-inline-skill-background: #324e66;
+ --color-inline-skill-border: rgba(112, 185, 238, 0.42);
+ --color-inline-skill-foreground: #fffaff;
+ --color-primary: #70b9ee;
+ --color-primary-foreground: #241523;
+ --color-primary-shadow: #000000;
+ --color-secondary: #293f52;
+ --color-secondary-foreground: #fffaff;
+ --color-secondary-border: #405567;
+ --color-switch-active-track: #70b9ee;
+ --color-switch-active-thumb: #241523;
+ --color-switch-inactive-track: #293f52;
+ --color-switch-inactive-thumb: #969ca6;
+ --color-danger: #3c2630;
+ --color-danger-border: rgba(251, 65, 74, 0.32);
+ --color-danger-foreground: #ff6467;
+ --color-input: #333b45;
+ --color-input-border: #4f677b;
+ --color-sidebar-search: #424e5a;
+ --color-placeholder: #a4a4ac;
+ --color-icon: #fffaff;
+ --color-icon-muted: #8d8f97;
+ --color-icon-subtle: #8d8f97;
+ --color-header: rgba(23, 33, 43, 0.97);
+ --color-header-border: #36566f;
+ --color-glass-surface: rgba(65, 72, 81, 0.74);
+ --color-glass-tint: rgba(65, 72, 81, 0.22);
+ --color-status-bar: #17212b;
+ --color-md-body: #fffaff;
+ --color-md-strong: #fffaff;
+ --color-md-link: #70b9ee;
+ --color-md-blockquote-border: #405567;
+ --color-md-blockquote-bg: #233544;
+ --color-md-code-bg: #252e38;
+ --color-md-code-text: #fffaff;
+ --color-md-user-code-bg: rgba(255, 250, 255, 0.18);
+ --color-md-user-code-text: #fffaff;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
+ --color-md-user-fence-text: #fffaff;
+ --color-md-hr: #405567;
+ --color-user-bubble: #375871;
+ --color-user-bubble-foreground: #fffaff;
+ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78);
+ --color-user-bubble-skill-foreground: #75d8dd;
+ --color-backdrop: rgba(0, 0, 0, 0.48);
+ --color-drawer: rgba(30, 45, 59, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.32);
+ --color-dot-separator: rgba(141, 143, 151, 0.35);
+ --color-wordmark: #fffaff;
+ --color-chevron: rgba(141, 143, 151, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
+ --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
+ --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
+ --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
+ --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%);
+ --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
+ --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
+ --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%);
+ --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-neutral-100-900: oklch(20.5% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%);
+ --color-adaptive-neutral-200-800: oklch(26.9% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%);
+ --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%);
+ --color-adaptive-neutral-400-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%);
+ --color-adaptive-neutral-500-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(87% 0 0);
+ --color-adaptive-neutral-600-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-950-50: oklch(98.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
+ --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
+ --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
+ --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571);
+ --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%);
+ --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%);
+ --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
+ --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
+ --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541);
+ --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571);
+ --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%);
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%);
+ --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067);
+ --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286);
+ }
+
+ @variant ember-light {
+ --color-screen: #f9f7f5;
+ --color-sheet: rgba(249, 247, 245, 0.98);
+ --color-sheet-solid: #f9f7f5;
+ --color-card: #f1efee;
+ --color-card-alt: #f9f7f5;
+ --color-card-translucent: rgba(241, 239, 238, 0.8);
+ --color-foreground: #241523;
+ --color-foreground-secondary: #766c74;
+ --color-foreground-muted: #74686f;
+ --color-foreground-tertiary: #766c74;
+ --color-border: #ddd2ce;
+ --color-border-subtle: rgba(221, 210, 206, 0.7);
+ --color-separator: rgba(221, 210, 206, 0.55);
+ --color-subtle: #f4ede9;
+ --color-subtle-strong: #f3eae5;
+ --color-inline-skill-background: #eee0d9;
+ --color-inline-skill-border: rgba(174, 85, 42, 0.42);
+ --color-inline-skill-foreground: #241523;
+ --color-primary: #ae552a;
+ --color-primary-foreground: #fffaff;
+ --color-primary-shadow: #000000;
+ --color-secondary: #f3eae5;
+ --color-secondary-foreground: #241523;
+ --color-secondary-border: #ddd2ce;
+ --color-switch-active-track: #ae552a;
+ --color-switch-active-thumb: #fffaff;
+ --color-switch-inactive-track: #f3eae5;
+ --color-switch-inactive-thumb: #74686f;
+ --color-danger: #f9e7e6;
+ --color-danger-border: rgba(251, 44, 54, 0.32);
+ --color-danger-foreground: #c10007;
+ --color-input: #f1efee;
+ --color-input-border: #d4c6c1;
+ --color-sidebar-search: #e2d9d6;
+ --color-placeholder: #736971;
+ --color-icon: #241523;
+ --color-icon-muted: #766c74;
+ --color-icon-subtle: #766c74;
+ --color-header: rgba(249, 247, 245, 0.97);
+ --color-header-border: #eee0d9;
+ --color-glass-surface: rgba(236, 233, 233, 0.74);
+ --color-glass-tint: rgba(236, 233, 233, 0.22);
+ --color-status-bar: #f9f7f5;
+ --color-md-body: #241523;
+ --color-md-strong: #241523;
+ --color-md-link: #ae552a;
+ --color-md-blockquote-border: #ddd2ce;
+ --color-md-blockquote-bg: #f4ede9;
+ --color-md-code-bg: #f3f1f0;
+ --color-md-code-text: #241523;
+ --color-md-user-code-bg: rgba(36, 21, 35, 0.18);
+ --color-md-user-code-text: #241523;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
+ --color-md-user-fence-text: #241523;
+ --color-md-hr: #ddd2ce;
+ --color-user-bubble: #ebdad1;
+ --color-user-bubble-foreground: #241523;
+ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78);
+ --color-user-bubble-skill-foreground: #b13535;
+ --color-backdrop: rgba(0, 0, 0, 0.22);
+ --color-drawer: rgba(243, 234, 229, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.12);
+ --color-dot-separator: rgba(118, 108, 116, 0.35);
+ --color-wordmark: #241523;
+ --color-chevron: rgba(118, 108, 116, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
+ --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
+ --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%);
+ --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815);
+ --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
+ --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
+ --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%);
+ --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966);
+ --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023);
+ --color-adaptive-neutral-100-900: oklch(97% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-800: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%);
+ --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%);
+ --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%);
+ --color-adaptive-neutral-400-500: oklch(70.8% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%);
+ --color-adaptive-neutral-500-400: oklch(55.6% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(43.9% 0 0);
+ --color-adaptive-neutral-600-400: oklch(43.9% 0 0);
+ --color-adaptive-neutral-950-50: oklch(14.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
+ --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
+ --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
+ --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518);
+ --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58);
+ --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439);
+ --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
+ --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
+ --color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
+ --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009);
+ --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581);
+ --color-adaptive-white-neutral-950-a70: #fff;
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%);
+ --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938);
+ --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786);
+ }
+
+ @variant ember-dark {
+ --color-screen: #291e1a;
+ --color-sheet: rgba(41, 30, 26, 0.98);
+ --color-sheet-solid: #291e1a;
+ --color-card: #433835;
+ --color-card-alt: #291e1a;
+ --color-card-translucent: rgba(67, 56, 53, 0.8);
+ --color-foreground: #fffaff;
+ --color-foreground-secondary: #968e8f;
+ --color-foreground-muted: #a59996;
+ --color-foreground-tertiary: #968e8f;
+ --color-border: #664c3f;
+ --color-border-subtle: rgba(102, 76, 63, 0.7);
+ --color-separator: rgba(102, 76, 63, 0.55);
+ --color-subtle: #432e23;
+ --color-subtle-strong: #513728;
+ --color-inline-skill-background: #644330;
+ --color-inline-skill-border: rgba(240, 154, 100, 0.42);
+ --color-inline-skill-foreground: #fffaff;
+ --color-primary: #f09a64;
+ --color-primary-foreground: #241523;
+ --color-primary-shadow: #000000;
+ --color-secondary: #513728;
+ --color-secondary-foreground: #fffaff;
+ --color-secondary-border: #664c3f;
+ --color-switch-active-track: #f09a64;
+ --color-switch-active-thumb: #241523;
+ --color-switch-inactive-track: #513728;
+ --color-switch-inactive-thumb: #a59996;
+ --color-danger: #4a2321;
+ --color-danger-border: rgba(251, 65, 74, 0.32);
+ --color-danger-foreground: #ff6467;
+ --color-input: #433835;
+ --color-input-border: #7a5d4d;
+ --color-sidebar-search: #584943;
+ --color-placeholder: #aba3a5;
+ --color-icon: #fffaff;
+ --color-icon-muted: #968e8f;
+ --color-icon-subtle: #968e8f;
+ --color-header: rgba(41, 30, 26, 0.97);
+ --color-header-border: #6e4934;
+ --color-glass-surface: rgba(79, 69, 67, 0.74);
+ --color-glass-tint: rgba(79, 69, 67, 0.22);
+ --color-status-bar: #291e1a;
+ --color-md-body: #fffaff;
+ --color-md-strong: #fffaff;
+ --color-md-link: #f09a64;
+ --color-md-blockquote-border: #664c3f;
+ --color-md-blockquote-bg: #432e23;
+ --color-md-code-bg: #362b27;
+ --color-md-code-text: #fffaff;
+ --color-md-user-code-bg: rgba(255, 250, 255, 0.18);
+ --color-md-user-code-text: #fffaff;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
+ --color-md-user-fence-text: #fffaff;
+ --color-md-hr: #664c3f;
+ --color-user-bubble: #704b34;
+ --color-user-bubble-foreground: #fffaff;
+ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78);
+ --color-user-bubble-skill-foreground: #fab6ad;
+ --color-backdrop: rgba(0, 0, 0, 0.48);
+ --color-drawer: rgba(57, 40, 31, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.32);
+ --color-dot-separator: rgba(150, 142, 143, 0.35);
+ --color-wordmark: #fffaff;
+ --color-chevron: rgba(150, 142, 143, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
+ --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
+ --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
+ --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
+ --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%);
+ --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
+ --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
+ --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%);
+ --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-neutral-100-900: oklch(20.5% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%);
+ --color-adaptive-neutral-200-800: oklch(26.9% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%);
+ --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%);
+ --color-adaptive-neutral-400-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%);
+ --color-adaptive-neutral-500-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(87% 0 0);
+ --color-adaptive-neutral-600-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-950-50: oklch(98.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
+ --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
+ --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
+ --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571);
+ --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%);
+ --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%);
+ --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
+ --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
+ --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541);
+ --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571);
+ --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%);
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%);
+ --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067);
+ --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286);
+ }
+
+ @variant iris-light {
+ --color-screen: #f8f7f9;
+ --color-sheet: rgba(248, 247, 249, 0.98);
+ --color-sheet-solid: #f8f7f9;
+ --color-card: #f0eff2;
+ --color-card-alt: #f8f7f9;
+ --color-card-translucent: rgba(240, 239, 242, 0.8);
+ --color-foreground: #241523;
+ --color-foreground-secondary: #766c76;
+ --color-foreground-muted: #726874;
+ --color-foreground-tertiary: #766c76;
+ --color-border: #d6d1de;
+ --color-border-subtle: rgba(214, 209, 222, 0.7);
+ --color-separator: rgba(214, 209, 222, 0.55);
+ --color-subtle: #f0edf6;
+ --color-subtle-strong: #edeaf4;
+ --color-inline-skill-background: #e5e0f0;
+ --color-inline-skill-border: rgba(114, 83, 185, 0.42);
+ --color-inline-skill-foreground: #241523;
+ --color-primary: #7253b9;
+ --color-primary-foreground: #fffaff;
+ --color-primary-shadow: #000000;
+ --color-secondary: #edeaf4;
+ --color-secondary-foreground: #241523;
+ --color-secondary-border: #d6d1de;
+ --color-switch-active-track: #7253b9;
+ --color-switch-active-thumb: #fffaff;
+ --color-switch-inactive-track: #edeaf4;
+ --color-switch-inactive-thumb: #726874;
+ --color-danger: #f8e6ea;
+ --color-danger-border: rgba(251, 44, 54, 0.32);
+ --color-danger-foreground: #c10007;
+ --color-input: #f0eff2;
+ --color-input-border: #ccc5d6;
+ --color-sidebar-search: #ddd9e3;
+ --color-placeholder: #736973;
+ --color-icon: #241523;
+ --color-icon-muted: #766c76;
+ --color-icon-subtle: #766c76;
+ --color-header: rgba(248, 247, 249, 0.97);
+ --color-header-border: #e5e0f0;
+ --color-glass-surface: rgba(235, 233, 237, 0.74);
+ --color-glass-tint: rgba(235, 233, 237, 0.22);
+ --color-status-bar: #f8f7f9;
+ --color-md-body: #241523;
+ --color-md-strong: #241523;
+ --color-md-link: #7253b9;
+ --color-md-blockquote-border: #d6d1de;
+ --color-md-blockquote-bg: #f0edf6;
+ --color-md-code-bg: #f2f1f4;
+ --color-md-code-text: #241523;
+ --color-md-user-code-bg: rgba(36, 21, 35, 0.18);
+ --color-md-user-code-text: #241523;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.16);
+ --color-md-user-fence-text: #241523;
+ --color-md-hr: #d6d1de;
+ --color-user-bubble: #e0d9ee;
+ --color-user-bubble-foreground: #241523;
+ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78);
+ --color-user-bubble-skill-foreground: #a82c87;
+ --color-backdrop: rgba(0, 0, 0, 0.22);
+ --color-drawer: rgba(237, 234, 244, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.12);
+ --color-dot-separator: rgba(118, 108, 118, 0.35);
+ --color-wordmark: #241523;
+ --color-chevron: rgba(118, 108, 118, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277);
+ --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%);
+ --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998);
+ --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201);
+ --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%);
+ --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815);
+ --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%);
+ --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225);
+ --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%);
+ --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966);
+ --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023);
+ --color-adaptive-neutral-100-900: oklch(97% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-800: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 0 / 70%);
+ --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 0);
+ --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 0 / 80%);
+ --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 0 / 60%);
+ --color-adaptive-neutral-400-500: oklch(70.8% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 10%);
+ --color-adaptive-neutral-500-400: oklch(55.6% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(43.9% 0 0);
+ --color-adaptive-neutral-600-400: oklch(43.9% 0 0);
+ --color-adaptive-neutral-950-50: oklch(14.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38);
+ --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334);
+ --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%);
+ --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518);
+ --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58);
+ --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439);
+ --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585);
+ --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%);
+ --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966);
+ --color-adaptive-sky-700-300: oklch(50% 0.134 242.749);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%);
+ --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009);
+ --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581);
+ --color-adaptive-white-neutral-950-a70: #fff;
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%);
+ --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938);
+ --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786);
+ }
+
+ @variant iris-dark {
+ --color-screen: #1d1929;
+ --color-sheet: rgba(29, 25, 41, 0.98);
+ --color-sheet-solid: #1d1929;
+ --color-card: #383443;
+ --color-card-alt: #1d1929;
+ --color-card-translucent: rgba(56, 52, 67, 0.8);
+ --color-foreground: #fffaff;
+ --color-foreground-secondary: #8e8a95;
+ --color-foreground-muted: #9690a1;
+ --color-foreground-tertiary: #8e8a95;
+ --color-border: #4d4366;
+ --color-border-subtle: rgba(77, 67, 102, 0.7);
+ --color-separator: rgba(77, 67, 102, 0.55);
+ --color-subtle: #2d2643;
+ --color-subtle-strong: #362d51;
+ --color-inline-skill-background: #433765;
+ --color-inline-skill-border: rgba(157, 125, 242, 0.42);
+ --color-inline-skill-foreground: #fffaff;
+ --color-primary: #9d7df2;
+ --color-primary-foreground: #241523;
+ --color-primary-shadow: #000000;
+ --color-secondary: #362d51;
+ --color-secondary-foreground: #fffaff;
+ --color-secondary-border: #4d4366;
+ --color-switch-active-track: #9d7df2;
+ --color-switch-active-thumb: #241523;
+ --color-switch-inactive-track: #362d51;
+ --color-switch-inactive-thumb: #9690a1;
+ --color-danger: #40202e;
+ --color-danger-border: rgba(251, 65, 74, 0.32);
+ --color-danger-foreground: #ff6467;
+ --color-input: #383443;
+ --color-input-border: #5d527b;
+ --color-sidebar-search: #494459;
+ --color-placeholder: #a29ea8;
+ --color-icon: #fffaff;
+ --color-icon-muted: #8e8a95;
+ --color-icon-subtle: #8e8a95;
+ --color-header: rgba(29, 25, 41, 0.97);
+ --color-header-border: #4a3c70;
+ --color-glass-surface: rgba(69, 66, 80, 0.74);
+ --color-glass-tint: rgba(69, 66, 80, 0.22);
+ --color-status-bar: #1d1929;
+ --color-md-body: #fffaff;
+ --color-md-strong: #fffaff;
+ --color-md-link: #9d7df2;
+ --color-md-blockquote-border: #4d4366;
+ --color-md-blockquote-bg: #2d2643;
+ --color-md-code-bg: #2a2736;
+ --color-md-code-text: #fffaff;
+ --color-md-user-code-bg: rgba(255, 250, 255, 0.18);
+ --color-md-user-code-text: #fffaff;
+ --color-md-user-fence-bg: rgba(0, 0, 0, 0.28);
+ --color-md-user-fence-text: #fffaff;
+ --color-md-hr: #4d4366;
+ --color-user-bubble: #4b3d72;
+ --color-user-bubble-foreground: #fffaff;
+ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78);
+ --color-user-bubble-skill-foreground: #f099d8;
+ --color-backdrop: rgba(0, 0, 0, 0.48);
+ --color-drawer: rgba(39, 33, 57, 0.99);
+ --color-drawer-shadow: rgba(0, 0, 0, 0.32);
+ --color-dot-separator: rgba(142, 138, 149, 0.35);
+ --color-wordmark: #fffaff;
+ --color-chevron: rgba(142, 138, 149, 0.42);
+ --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%);
+ --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%);
+ --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%);
+ --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605);
+ --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429);
+ --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746);
+ --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%);
+ --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%);
+ --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%);
+ --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624);
+ --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%);
+ --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%);
+ --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%);
+ --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223);
+ --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978);
+ --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%);
+ --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713);
+ --color-adaptive-neutral-100-900: oklch(20.5% 0 0);
+ --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 0 / 60%);
+ --color-adaptive-neutral-200-800: oklch(26.9% 0 0);
+ --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%);
+ --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%);
+ --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%);
+ --color-adaptive-neutral-400-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 0 / 60%);
+ --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 0 / 80%);
+ --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 0 / 16%);
+ --color-adaptive-neutral-500-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-500-500: oklch(55.6% 0 0);
+ --color-adaptive-neutral-600-300: oklch(87% 0 0);
+ --color-adaptive-neutral-600-400: oklch(70.8% 0 0);
+ --color-adaptive-neutral-950-50: oklch(98.5% 0 0);
+ --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%);
+ --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899);
+ --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%);
+ --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571);
+ --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%);
+ --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%);
+ --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%);
+ --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%);
+ --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428);
+ --color-adaptive-rose-700-300: oklch(81% 0.117 11.638);
+ --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%);
+ --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661);
+ --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318);
+ --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%);
+ --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541);
+ --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571);
+ --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 0 / 70%);
+ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%);
+ --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067);
+ --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286);
+ }
+ }
+}
diff --git a/apps/mobile/global.css b/apps/mobile/global.css
index a42afc74d9..e6961eac4e 100644
--- a/apps/mobile/global.css
+++ b/apps/mobile/global.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "uniwind";
+@import "./generated-uniwind-themes.css";
/* ─── Theme tokens ──────────────────────────────────────────────────── */
@layer theme {
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
index fe88607769..3791d347c6 100644
--- a/apps/mobile/metro.config.js
+++ b/apps/mobile/metro.config.js
@@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { getDefaultConfig } = require("expo/metro-config");
const { withUniwindConfig } = require("uniwind/metro");
+const extraThemes = require("./generated-uniwind-theme-names.json");
/** @type {import("expo/metro-config").MetroConfig} */
const config = getDefaultConfig(__dirname);
@@ -50,5 +51,6 @@ config.resolver = {
module.exports = withUniwindConfig(config, {
cssEntryFile: "./global.css",
+ extraThemes,
polyfills: { rem: 14 },
});
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 1097f6a337..3c378adbc0 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -39,6 +39,7 @@
"config:prod": "APP_VARIANT=production expo config",
"profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",
"sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs",
+ "generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts",
"test": "vp test run",
"typecheck": "tsc --noEmit"
},
diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts
new file mode 100644
index 0000000000..aa3d9b0bfb
--- /dev/null
+++ b/apps/mobile/scripts/generate-uniwind-themes.mts
@@ -0,0 +1,262 @@
+#!/usr/bin/env node
+
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+import tailwindColors from "tailwindcss/colors";
+import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes";
+
+import {
+ getMobileThemeVariables,
+ MOBILE_THEME_VARIABLE_NAMES,
+ type MobileThemeAppearance,
+ type MobileThemeVariables,
+} from "../src/lib/mobileTheme.ts";
+
+const APPEARANCES = ["light", "dark"] as const;
+const GLOBAL_CSS_PATH = NodePath.resolve(import.meta.dirname, "../global.css");
+const GENERATED_CSS_PATH = NodePath.resolve(import.meta.dirname, "../generated-uniwind-themes.css");
+const GENERATED_NAMES_PATH = NodePath.resolve(
+ import.meta.dirname,
+ "../generated-uniwind-theme-names.json",
+);
+const GENERATED_DEFAULT_VARIABLES_PATH = NodePath.resolve(
+ import.meta.dirname,
+ "../generated-uniwind-default-theme-variables.json",
+);
+
+type TailwindColorFamily = keyof typeof tailwindColors;
+type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
+
+const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity = 1): string => {
+ const familyColors = tailwindColors[family];
+ const value =
+ typeof familyColors === "string"
+ ? shade === undefined
+ ? familyColors
+ : undefined
+ : shade === undefined
+ ? undefined
+ : familyColors[String(shade) as keyof typeof familyColors];
+ if (value === undefined) {
+ throw new Error(`Unknown Tailwind color ${family}${shade === undefined ? "" : `-${shade}`}.`);
+ }
+ if (opacity === 1) return value;
+
+ const percentage = Number((opacity * 100).toFixed(4));
+ const oklch = /^oklch\((.*)\)$/.exec(value);
+ if (oklch) return `oklch(${oklch[1]} / ${percentage}%)`;
+ if (value === "#fff") return `rgb(255 255 255 / ${percentage}%)`;
+ if (value === "#000") return `rgb(0 0 0 / ${percentage}%)`;
+ return `color-mix(in srgb, ${value} ${percentage}%, transparent)`;
+};
+
+// These replace the remaining dark:* utility pairs. A registered palette theme is
+// neither literally `light` nor `dark`, so appearance-sensitive values must also be
+// represented as semantic variables for custom themes.
+const ADAPTIVE_COLORS = {
+ "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)],
+ "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)],
+ "--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)],
+ "--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)],
+ "--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)],
+ "--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)],
+ "--color-adaptive-blue-50-blue-400-a14": [color("blue", 50), color("blue", 400, 0.14)],
+ "--color-adaptive-blue-300-a50-blue-400-a28": [color("blue", 300, 0.5), color("blue", 400, 0.28)],
+ "--color-adaptive-blue-500-a20-blue-400-a15": [color("blue", 500, 0.2), color("blue", 400, 0.15)],
+ "--color-adaptive-blue-500-400": [color("blue", 500), color("blue", 400)],
+ "--color-adaptive-blue-600-400": [color("blue", 600), color("blue", 400)],
+ "--color-adaptive-black-a10-a25": [
+ color("black", undefined, 0.1),
+ color("black", undefined, 0.25),
+ ],
+ "--color-adaptive-black-a15-a35": [
+ color("black", undefined, 0.15),
+ color("black", undefined, 0.35),
+ ],
+ "--color-adaptive-emerald-500-a12-a16": [
+ color("emerald", 500, 0.12),
+ color("emerald", 500, 0.16),
+ ],
+ "--color-adaptive-emerald-600-400": [color("emerald", 600), color("emerald", 400)],
+ "--color-adaptive-emerald-700-300": [color("emerald", 700), color("emerald", 300)],
+ "--color-adaptive-indigo-500-a12-a16": [color("indigo", 500, 0.12), color("indigo", 500, 0.16)],
+ "--color-adaptive-indigo-600-300": [color("indigo", 600), color("indigo", 300)],
+ "--color-adaptive-indigo-700-300": [color("indigo", 700), color("indigo", 300)],
+ "--color-adaptive-neutral-100-900": [color("neutral", 100), color("neutral", 900)],
+ "--color-adaptive-neutral-200-700-a60": [color("neutral", 200), color("neutral", 700, 0.6)],
+ "--color-adaptive-neutral-200-800": [color("neutral", 200), color("neutral", 800)],
+ "--color-adaptive-neutral-200-a70-white-a8": [
+ color("neutral", 200, 0.7),
+ color("white", undefined, 0.08),
+ ],
+ "--color-adaptive-neutral-200-white-a6": [color("neutral", 200), color("white", undefined, 0.06)],
+ "--color-adaptive-neutral-200-white-a8": [color("neutral", 200), color("white", undefined, 0.08)],
+ "--color-adaptive-neutral-200-a80-white-a8": [
+ color("neutral", 200, 0.8),
+ color("white", undefined, 0.08),
+ ],
+ "--color-adaptive-neutral-300-a60-white-a12": [
+ color("neutral", 300, 0.6),
+ color("white", undefined, 0.12),
+ ],
+ "--color-adaptive-neutral-400-500": [color("neutral", 400), color("neutral", 500)],
+ "--color-adaptive-neutral-400-a60-500-a60": [
+ color("neutral", 400, 0.6),
+ color("neutral", 500, 0.6),
+ ],
+ "--color-adaptive-neutral-400-a80-500-a80": [
+ color("neutral", 400, 0.8),
+ color("neutral", 500, 0.8),
+ ],
+ "--color-adaptive-neutral-500-a10-a16": [color("neutral", 500, 0.1), color("neutral", 500, 0.16)],
+ "--color-adaptive-neutral-500-400": [color("neutral", 500), color("neutral", 400)],
+ "--color-adaptive-neutral-500-500": [color("neutral", 500), color("neutral", 500)],
+ "--color-adaptive-neutral-600-300": [color("neutral", 600), color("neutral", 300)],
+ "--color-adaptive-neutral-600-400": [color("neutral", 600), color("neutral", 400)],
+ "--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)],
+ "--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)],
+ "--color-adaptive-red-200-800": [color("red", 200), color("red", 800)],
+ "--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)],
+ "--color-adaptive-red-700-300": [color("red", 700), color("red", 300)],
+ "--color-adaptive-rose-100-500-a18": [color("rose", 100), color("rose", 500, 0.18)],
+ "--color-adaptive-rose-100-a80-500-a12": [color("rose", 100, 0.8), color("rose", 500, 0.12)],
+ "--color-adaptive-rose-300-a70-400-a28": [color("rose", 300, 0.7), color("rose", 400, 0.28)],
+ "--color-adaptive-rose-500-a12-a16": [color("rose", 500, 0.12), color("rose", 500, 0.16)],
+ "--color-adaptive-rose-500-400": [color("rose", 500), color("rose", 400)],
+ "--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)],
+ "--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)],
+ "--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)],
+ "--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)],
+ "--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)],
+ "--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)],
+ "--color-adaptive-violet-600-400": [color("violet", 600), color("violet", 400)],
+ "--color-adaptive-violet-700-300": [color("violet", 700), color("violet", 300)],
+ "--color-adaptive-white-neutral-950-a70": [color("white"), color("neutral", 950, 0.7)],
+ "--color-adaptive-zinc-500-a12-a16": [color("zinc", 500, 0.12), color("zinc", 500, 0.16)],
+ "--color-adaptive-zinc-500-400": [color("zinc", 500), color("zinc", 400)],
+ "--color-adaptive-zinc-600-300": [color("zinc", 600), color("zinc", 300)],
+};
+
+export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) =>
+ APPEARANCES.map((appearance) => `${themeId}-${appearance}`),
+);
+
+const adaptiveVariablesFor = (appearance: MobileThemeAppearance) =>
+ Object.fromEntries(
+ Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [
+ name,
+ values[appearance === "light" ? 0 : 1],
+ ]),
+ );
+
+const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({
+ ...getMobileThemeVariables(themeId, appearance),
+ ...adaptiveVariablesFor(appearance),
+});
+
+const renderVariant = (name: string, variables: Readonly>) => {
+ const declarations = Object.entries(variables)
+ .map(([variable, value]) => ` ${variable}: ${value};`)
+ .join("\n");
+ return ` @variant ${name} {\n${declarations}\n }`;
+};
+
+export const renderUniwindThemesCSS = () => {
+ const variants = [
+ renderVariant("light", adaptiveVariablesFor("light")),
+ renderVariant("dark", adaptiveVariablesFor("dark")),
+ ...BUILT_IN_THEME_IDS.flatMap((themeId) =>
+ APPEARANCES.map((appearance) =>
+ renderVariant(`${themeId}-${appearance}`, variablesFor(themeId, appearance)),
+ ),
+ ),
+ ];
+ return [
+ "/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */",
+ "@layer theme {",
+ " :root {",
+ variants.join("\n\n"),
+ " }",
+ "}",
+ "",
+ ].join("\n");
+};
+
+const readVariantBody = (css: string, appearance: MobileThemeAppearance): string => {
+ const marker = `@variant ${appearance} {`;
+ const markerIndex = css.indexOf(marker);
+ if (markerIndex === -1) throw new Error(`Could not find ${marker} in global.css.`);
+
+ const openingBraceIndex = css.indexOf("{", markerIndex);
+ let depth = 0;
+ for (let index = openingBraceIndex; index < css.length; index += 1) {
+ if (css[index] === "{") depth += 1;
+ if (css[index] !== "}") continue;
+ depth -= 1;
+ if (depth === 0) return css.slice(openingBraceIndex + 1, index);
+ }
+ throw new Error(`Could not find the end of ${marker} in global.css.`);
+};
+
+export const readDefaultThemeVariables = (css: string) =>
+ Object.fromEntries(
+ APPEARANCES.map((appearance) => {
+ const body = readVariantBody(css, appearance);
+ const variables = Object.fromEntries(
+ MOBILE_THEME_VARIABLE_NAMES.map((name) => {
+ const match = new RegExp(`^\\s*${name}:\\s*([^;]+);`, "mu").exec(body);
+ if (!match?.[1]) {
+ throw new Error(`Default ${appearance} theme is missing ${name}.`);
+ }
+ return [name, match[1].trim()];
+ }),
+ ) as MobileThemeVariables;
+ return [appearance, variables];
+ }),
+ ) as Readonly>;
+
+export const renderDefaultThemeVariablesJSON = (css: string) =>
+ `${JSON.stringify(readDefaultThemeVariables(css), null, 2)}\n`;
+
+export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray<
+ readonly [filename: string, contents: string]
+> => [
+ [GENERATED_CSS_PATH, renderUniwindThemesCSS()],
+ [GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`],
+ [
+ GENERATED_DEFAULT_VARIABLES_PATH,
+ renderDefaultThemeVariablesJSON(NodeFS.readFileSync(GLOBAL_CSS_PATH, "utf8")),
+ ],
+];
+
+const writeFileAtomically = (filename: string, contents: string) => {
+ const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
+ if (current === contents) return;
+
+ const temporaryFilename = `${filename}.${process.pid}.tmp`;
+ try {
+ NodeFS.writeFileSync(temporaryFilename, contents);
+ NodeFS.renameSync(temporaryFilename, filename);
+ } finally {
+ if (NodeFS.existsSync(temporaryFilename)) NodeFS.unlinkSync(temporaryFilename);
+ }
+};
+
+if (import.meta.main) {
+ const checkOnly = process.argv.includes("--check");
+ for (const [filename, contents] of getGeneratedUniwindThemeOutputs()) {
+ if (checkOnly) {
+ const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
+ if (current !== contents) {
+ console.error(
+ `${NodePath.relative(process.cwd(), filename)} is stale. Run vp run --filter @t3tools/mobile generate.`,
+ );
+ process.exitCode = 1;
+ }
+ continue;
+ }
+ // Metro watches the generated CSS. Replacing a complete temporary file keeps
+ // Tailwind from compiling a partially rewritten theme file.
+ writeFileAtomically(filename, contents);
+ }
+}
diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts
new file mode 100644
index 0000000000..48126055ba
--- /dev/null
+++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts
@@ -0,0 +1,55 @@
+import * as NodeFS from "node:fs";
+import * as NodePath from "node:path";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ customThemeNames,
+ getGeneratedUniwindThemeOutputs,
+ readDefaultThemeVariables,
+ renderUniwindThemesCSS,
+} from "./generate-uniwind-themes.mts";
+
+describe("generate mobile Uniwind themes", () => {
+ it("keeps the committed outputs current", () => {
+ const staleOutputs = getGeneratedUniwindThemeOutputs()
+ .filter(
+ ([filename, contents]) =>
+ !NodeFS.existsSync(filename) || NodeFS.readFileSync(filename, "utf8") !== contents,
+ )
+ .map(([filename]) => NodePath.relative(import.meta.dirname, filename));
+
+ expect(
+ staleOutputs,
+ "Run `vp run --filter @t3tools/mobile generate` and commit the generated outputs.",
+ ).toEqual([]);
+ });
+
+ it("registers every custom palette for both appearances", () => {
+ expect(customThemeNames).toEqual([
+ "t3-chat-light",
+ "t3-chat-dark",
+ "grove-light",
+ "grove-dark",
+ "ocean-light",
+ "ocean-dark",
+ "ember-light",
+ "ember-dark",
+ "iris-light",
+ "iris-dark",
+ ]);
+
+ const stylesheet = renderUniwindThemesCSS();
+ for (const themeName of customThemeNames) {
+ expect(stylesheet.match(new RegExp(`@variant ${themeName} \\{`, "gu"))).toHaveLength(1);
+ }
+ });
+
+ it("generates the default runtime bridge from the authored CSS", () => {
+ const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8");
+ const variables = readDefaultThemeVariables(css);
+
+ expect(variables.light["--color-screen"]).toBe("#f2f2f7");
+ expect(variables.dark["--color-screen"]).toBe("#0a0a0a");
+ expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark));
+ });
+});
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 8b219afcc0..c268056f03 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -21,7 +21,6 @@ import { RootStack } from "./Stack";
import { appAtomRegistry } from "./state/atom-registry";
import { OverlayPortalHost } from "./components/OverlayPortal";
import { appBlurTargetRef } from "./lib/appBlurTarget";
-import { useThemeColor } from "./lib/useThemeColor";
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import "../global.css";
@@ -72,8 +71,7 @@ export default function App() {
function AppContent() {
const { themeAppearance } = useAppearancePreferences();
- const statusBarBg = useThemeColor("--color-status-bar");
- const navigationTheme = useMobileNavigationTheme(themeAppearance);
+ const navigationTheme = useMobileNavigationTheme();
return (
<>
@@ -83,7 +81,6 @@ function AppContent() {
{/* The navigation theme drives the NATIVE header appearance: native-stack
diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx
index 7a27e0c3b1..b4e545fade 100644
--- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx
+++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx
@@ -9,7 +9,6 @@ import Animated, { FadeIn } from "react-native-reanimated";
import { appBlurTargetRef } from "../lib/appBlurTarget";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
-import { useThemeColor } from "../lib/useThemeColor";
import { cn } from "../lib/cn";
import { type AppSymbolName, SymbolView } from "./AppSymbol";
import { AppText as Text } from "./AppText";
@@ -84,11 +83,6 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const isDarkMode = themeAppearance === "dark";
const keyboardVisible = useKeyboardState((state) => state.isVisible);
const keyboardHeight = useKeyboardState((state) => state.height);
- const rippleColor = useThemeColor("--color-subtle");
- const iconColor = useThemeColor("--color-icon");
- const iconSubtleColor = useThemeColor("--color-icon-subtle");
- const dangerColor = useThemeColor("--color-danger-foreground");
-
const close = useCallback(() => {
setAnchor(null);
setPath([]);
@@ -279,10 +273,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
return (
onPressItem(action)}
@@ -307,21 +300,23 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
) : action.state === "on" ? (
) : action.image ? (
) : null}
diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx
index 7fe21fb44f..46bc2c7c09 100644
--- a/apps/mobile/src/components/AndroidScreenHeader.tsx
+++ b/apps/mobile/src/components/AndroidScreenHeader.tsx
@@ -5,7 +5,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { SymbolView, type AppSymbolName } from "./AppSymbol";
import { AppText as Text } from "./AppText";
import { cn } from "../lib/cn";
-import { useThemeColor } from "../lib/useThemeColor";
export interface AndroidHeaderAction {
readonly accessibilityLabel: string;
@@ -20,9 +19,6 @@ export function AndroidHeaderIconButton(props: {
readonly onPress?: () => void;
readonly disabled?: boolean;
}) {
- const foregroundColor = useThemeColor("--color-foreground");
- const disabledColor = useThemeColor("--color-icon-subtle");
-
return (
@@ -54,7 +50,6 @@ export function AndroidScreenHeader(props: {
readonly embedded?: boolean;
}) {
const insets = useSafeAreaInsets();
- const foregroundColor = useThemeColor("--color-foreground");
return (
diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx
index ef39359d4c..12755bc1ae 100644
--- a/apps/mobile/src/components/AppSymbol.tsx
+++ b/apps/mobile/src/components/AppSymbol.tsx
@@ -82,6 +82,7 @@ import {
} from "@tabler/icons-react-native";
import { Platform } from "react-native";
import { SymbolView as ExpoSymbolView, type SFSymbol, type SymbolViewProps } from "expo-symbols";
+import { withUniwind } from "uniwind";
const ANDROID_ICON_BY_SF_SYMBOL: Partial> = {
"arrow.branch": IconGitBranch,
@@ -192,7 +193,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME: Record = {
export type { SFSymbol } from "expo-symbols";
export type AppSymbolName = SymbolViewProps["name"];
-export function SymbolView(props: SymbolViewProps) {
+function AppSymbolView(props: SymbolViewProps) {
if (Platform.OS !== "android") {
return ;
}
@@ -218,3 +219,11 @@ export function SymbolView(props: SymbolViewProps) {
/>
);
}
+
+/**
+ * expo-symbols and the Android Tabler fallback both expose tint as a native
+ * prop rather than a React Native style. Keep that third-party boundary here
+ * so callers can use Uniwind's `tintColorClassName` instead of subscribing to
+ * theme variables in every parent component.
+ */
+export const SymbolView = withUniwind(AppSymbolView);
diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx
index bfba418c9f..9419a74e5f 100644
--- a/apps/mobile/src/components/CompactBrandTitle.tsx
+++ b/apps/mobile/src/components/CompactBrandTitle.tsx
@@ -9,7 +9,6 @@ import { AppText as Text } from "./AppText";
import { T3Wordmark } from "./T3Wordmark";
import { IPAD_HOME_TITLE_OFFSET } from "../lib/layoutMetrics";
import { resolveMobileStageLabel } from "../lib/mobileBranding";
-import { useThemeColor } from "../lib/useThemeColor";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass";
// Native leading items inherit different UIKit margins than title views.
@@ -37,9 +36,6 @@ export function CompactBrandTitle(
readonly nativeLeadingItem?: boolean;
} = {},
) {
- const iconColor = useThemeColor("--color-icon");
- const mutedColor = useThemeColor("--color-foreground-muted");
- const subtleColor = useThemeColor("--color-subtle");
const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
const titleOffset = brandTitleOffset(props.nativeLeadingItem === true);
@@ -49,42 +45,20 @@ export function CompactBrandTitle(
accessibilityLabel="T3 Code, Threads"
accessible
role="heading"
- style={{
- alignItems: "center",
- flexDirection: "row",
- gap: 6,
- marginLeft: titleOffset,
- }}
+ className="flex-row items-center gap-1.5"
+ style={{ marginLeft: titleOffset }}
>
-
+
Code
-
+
{stageLabel}
diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx
index 0621285c03..e3d42ca9cb 100644
--- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx
+++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx
@@ -1,6 +1,5 @@
import { SymbolView } from "../components/AppSymbol";
import { Image, Pressable, ScrollView, View } from "react-native";
-import { useThemeColor } from "../lib/useThemeColor";
import type { DraftComposerImageAttachment } from "../lib/composerImages";
@@ -24,7 +23,6 @@ export interface ComposerAttachmentStripProps {
* buttons. Used by both the thread composer and the new-task draft screen.
*/
export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
- const subtleBg = useThemeColor("--color-subtle");
const size = props.imageSize ?? 72;
const radius = props.imageBorderRadius ?? 16;
const removeButtonPlacement = props.removeButtonPlacement ?? "overlay";
@@ -56,11 +54,11 @@ export function ComposerAttachmentStrip(props: ComposerAttachmentStripProps) {
>
diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx
index de0af576ec..b64e44b193 100644
--- a/apps/mobile/src/components/ComposerToolbar.tsx
+++ b/apps/mobile/src/components/ComposerToolbar.tsx
@@ -11,9 +11,6 @@ import {
type ViewStyle,
} from "react-native";
-import { useThemeColor } from "../lib/useThemeColor";
-import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
-import { themeColorWithAlpha } from "../lib/mobileTheme";
import { cn } from "../lib/cn";
import { AppText as Text } from "./AppText";
import { SymbolView } from "./AppSymbol";
@@ -42,10 +39,6 @@ export function ComposerInlineControl(props: {
readonly chevronDirection?: "down" | "right";
readonly showChevron?: boolean;
}) {
- const iconColor = useThemeColor(
- props.emphasized || props.selected ? "--color-icon" : "--color-icon-muted",
- );
-
return (
{props.iconNode}
) : props.icon ? (
-
+
) : null}
)}
@@ -111,8 +113,12 @@ export function ComposerToolbarRow(props: {
export function ComposerToolbarScroller(props: {
readonly children: ReactNode;
- readonly fadeOpaque: string;
- readonly fadeTransparent: string;
+ /** Only for non-Uniwind surfaces such as the native terminal palette. */
+ readonly fadeOpaque?: string;
+ /** Only for non-Uniwind surfaces such as the native terminal palette. */
+ readonly fadeTransparent?: string;
+ /** Semantic Uniwind surface behind the toolbar. Defaults to card. */
+ readonly fadeSurface?: "card" | "sheet";
readonly contentPaddingRight?: number;
}) {
const [metrics, setMetrics] = useState({
@@ -170,27 +176,37 @@ export function ComposerToolbarScroller(props: {
{scrollEdges.showLeftFade ? (
) : null}
{scrollEdges.showRightFade ? (
) : null}
@@ -214,30 +230,16 @@ export function ComposerToolbarButton(props: {
readonly className?: string;
readonly style?: StyleProp;
}) {
- const { themeAppearance } = useAppearancePreferences();
- const isDarkMode = themeAppearance === "dark";
- const iconColor = useThemeColor("--color-icon");
- const iconSubtle = useThemeColor("--color-icon-subtle");
- const primaryFg = useThemeColor("--color-primary-foreground");
- const dangerFg = useThemeColor("--color-danger-foreground");
const variant = props.variant ?? "default";
const isCircle = !props.label && props.showChevron === false;
- const defaultBorderColor = useThemeColor("--color-border-subtle");
- const activeBorderColor = useThemeColor("--color-border");
- const filledBorderColor =
- variant === "danger"
- ? themeColorWithAlpha(String(dangerFg), 0.14)
- : props.disabled
- ? defaultBorderColor
- : themeColorWithAlpha(String(primaryFg), 0.18);
- const iconTintColor =
+ const iconTintClassName =
variant === "primary"
? props.disabled
- ? iconSubtle
- : primaryFg
+ ? "accent-icon-subtle"
+ : "accent-primary-foreground"
: variant === "danger"
- ? dangerFg
- : iconColor;
+ ? "accent-danger-foreground"
+ : "accent-icon";
return (
[
{
- borderColor:
- variant === "default"
- ? props.active
- ? activeBorderColor
- : defaultBorderColor
- : filledBorderColor,
- borderWidth: 1,
maxWidth: props.maxWidth,
minWidth: props.minWidth,
opacity: props.disabled ? 0.55 : pressed ? 0.72 : 1,
- shadowColor: "#000",
- shadowOffset: { width: 0, height: isDarkMode ? 3 : 2 },
- shadowOpacity: props.disabled ? 0 : isDarkMode ? 0.24 : 0.08,
- shadowRadius: isDarkMode ? 10 : 8,
},
props.style,
]}
@@ -286,7 +287,12 @@ export function ComposerToolbarButton(props: {
{props.iconNode ? (
{props.iconNode}
) : props.icon ? (
-
+
) : null}
{props.label ? (
) : null}
{props.showChevron === false ? null : (
-
+
)}
);
diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx
index 81daa3d6a2..521c5e36c3 100644
--- a/apps/mobile/src/components/ConfirmDialogHost.tsx
+++ b/apps/mobile/src/components/ConfirmDialogHost.tsx
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { Modal, Pressable, View } from "react-native";
-import { useThemeColor } from "../lib/useThemeColor";
import { cn } from "../lib/cn";
import { AppText } from "./AppText";
@@ -35,8 +34,6 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void {
*/
export function ConfirmDialogHost() {
const [request, setRequest] = useState(null);
- const pressedOverlay = useThemeColor("--color-subtle");
-
useEffect(() => {
presentRequest = setRequest;
return () => {
@@ -76,8 +73,7 @@ export function ConfirmDialogHost() {
@@ -88,8 +84,7 @@ export function ConfirmDialogHost() {
{props.iconNode}
) : props.icon ? (
-
+
) : null}
{props.label ? {props.label} : null}
diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx
index 76e06edcd1..6c12c9bdd8 100644
--- a/apps/mobile/src/components/ErrorBanner.tsx
+++ b/apps/mobile/src/components/ErrorBanner.tsx
@@ -3,10 +3,8 @@ import { View } from "react-native";
import { AppText as Text } from "./AppText";
export function ErrorBanner(props: { readonly message: string }) {
return (
-
-
- {props.message}
-
+
+ {props.message}
);
}
diff --git a/apps/mobile/src/components/GlassSafeAreaView.tsx b/apps/mobile/src/components/GlassSafeAreaView.tsx
index 8f91d61031..16a5fc4a47 100644
--- a/apps/mobile/src/components/GlassSafeAreaView.tsx
+++ b/apps/mobile/src/components/GlassSafeAreaView.tsx
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
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";
@@ -19,23 +18,13 @@ export function GlassSafeAreaView({
style,
}: GlassSafeAreaViewProps) {
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: headerColor,
- borderBottomWidth: 1,
- borderBottomColor: headerBorderColor,
- } as const;
return (
-
+
{
+import { cn } from "../lib/cn";
+
+// Explicit mappings keep the native glassEffectStyle enum out of style-array conversion.
+const ThemedGlassView = withUniwind(GlassView, {
+ style: { fromClassName: "className" },
+ tintColor: { fromClassName: "tintColorClassName", styleProperty: "accentColor" },
+});
+
+interface GlassSurfaceProps extends ViewProps {
readonly children: ReactNode;
readonly glassEffectStyle?: "clear" | "regular" | "none";
readonly tintColor?: ColorValue;
+ readonly tintColorClassName?: string;
readonly chrome?: "default" | "none";
/** Styling used only when native Liquid Glass is unavailable. */
readonly fallbackStyle?: StyleProp;
+ /** Uniwind styling used only when native Liquid Glass is unavailable. */
+ readonly fallbackClassName?: string;
}
export function GlassSurface({
@@ -25,22 +36,18 @@ export function GlassSurface({
glassEffectStyle = "regular",
chrome = "default",
tintColor,
+ tintColorClassName,
fallbackStyle,
+ fallbackClassName,
+ className,
style,
...props
}: GlassSurfaceProps) {
- const { themeAppearance } = useAppearancePreferences();
- const isDarkMode = themeAppearance === "dark";
- const borderColor = useThemeColor("--color-border");
- const glassSurface = useThemeColor("--color-glass-surface");
- const glassTint = useThemeColor("--color-glass-tint");
+ const isDarkMode = useColorScheme() === "dark";
const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable();
const surfaceStyle: ViewStyle = {
borderRadius: 32,
overflow: "hidden",
- borderWidth: chrome === "none" ? 0 : 1,
- borderColor: chrome === "none" ? "transparent" : borderColor,
- backgroundColor: chrome === "none" ? "transparent" : glassSurface,
shadowColor: chrome === "none" ? "transparent" : "#000000",
shadowOpacity: chrome === "none" ? 0 : isDarkMode ? 0.22 : 0.08,
shadowRadius: chrome === "none" ? 0 : 28,
@@ -59,20 +66,39 @@ export function GlassSurface({
if (supportsGlass) {
return (
-
{children}
-
+
);
}
return (
-
+
{children}
);
diff --git a/apps/mobile/src/components/LoadingScreen.tsx b/apps/mobile/src/components/LoadingScreen.tsx
index 275381a9c9..456a347d36 100644
--- a/apps/mobile/src/components/LoadingScreen.tsx
+++ b/apps/mobile/src/components/LoadingScreen.tsx
@@ -1,6 +1,5 @@
import { ActivityIndicator, StatusBar, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useThemeColor } from "../lib/useThemeColor";
import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
import { AppText as Text } from "./AppText";
@@ -11,17 +10,12 @@ export function LoadingScreen(props: {
readonly messagePlacement?: "above-spinner" | "below-spinner";
}) {
const { themeAppearance: colorScheme } = useAppearancePreferences();
- const screenBg = useThemeColor("--color-screen");
const insets = useSafeAreaInsets();
const messagePlacement = props.messagePlacement ?? "below-spinner";
return (
-
+
{messagePlacement === "above-spinner" ? (
diff --git a/apps/mobile/src/components/PierreEntryIcon.tsx b/apps/mobile/src/components/PierreEntryIcon.tsx
index 9cb6898fb9..cb73f5b7b1 100644
--- a/apps/mobile/src/components/PierreEntryIcon.tsx
+++ b/apps/mobile/src/components/PierreEntryIcon.tsx
@@ -3,7 +3,6 @@ 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";
-import { useThemeColor } from "../lib/useThemeColor";
export function PierreEntryIcon(props: {
readonly path: string;
@@ -12,9 +11,15 @@ export function PierreEntryIcon(props: {
readonly style?: StyleProp;
}) {
const size = props.size ?? 16;
- const folderColor = useThemeColor("--color-icon-subtle");
if (props.kind === "directory") {
- return ;
+ return (
+
+ );
}
return (
diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx
index c4297f24b0..c60709baf4 100644
--- a/apps/mobile/src/components/ProjectFavicon.tsx
+++ b/apps/mobile/src/components/ProjectFavicon.tsx
@@ -7,7 +7,6 @@ import {
getProjectFaviconCacheKey,
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
-import { useThemeColor } from "../lib/useThemeColor";
import { useAssetUrl } from "../state/assets";
import {
beginProjectFaviconRequest,
@@ -62,7 +61,6 @@ function ProjectFaviconImage(props: {
readonly projectTitle: string;
readonly size: number;
}) {
- const iconMuted = useThemeColor("--color-icon-subtle");
const faviconRequest = useMemo(
() => createProjectFaviconRequest(props.cacheKey, props.faviconUrl),
[props.cacheKey, props.faviconUrl],
@@ -97,7 +95,7 @@ function ProjectFaviconImage(props: {
) : null}
diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx
index b1d4918037..3b371c021a 100644
--- a/apps/mobile/src/components/SourceControlIcon.tsx
+++ b/apps/mobile/src/components/SourceControlIcon.tsx
@@ -1,4 +1,7 @@
import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
+import { withUniwind } from "uniwind";
+
+const ThemedSvg = withUniwind(Svg);
export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops";
@@ -6,20 +9,28 @@ export function SourceControlIcon(props: {
readonly kind: SourceControlIconKind;
readonly size?: number;
readonly color?: string;
+ readonly colorClassName?: string;
}) {
const size = props.size ?? 18;
switch (props.kind) {
case "github":
return (
-
+
);
case "gitlab":
return (
diff --git a/apps/mobile/src/components/T3Wordmark.tsx b/apps/mobile/src/components/T3Wordmark.tsx
index 81106557c6..5f64effffc 100644
--- a/apps/mobile/src/components/T3Wordmark.tsx
+++ b/apps/mobile/src/components/T3Wordmark.tsx
@@ -1,11 +1,18 @@
import type { ColorValue } from "react-native";
import Svg, { Path } from "react-native-svg";
+import { withUniwind } from "uniwind";
+
+const ThemedPath = withUniwind(Path);
/**
* The "T3" brand mark, matching the desktop sidebar's T3Wordmark SVG
* (apps/web Sidebar.tsx). Width derives from the viewBox aspect ratio.
*/
-export function T3Wordmark(props: { readonly height: number; readonly color: ColorValue }) {
+export function T3Wordmark(props: {
+ readonly height: number;
+ readonly color?: ColorValue;
+ readonly colorClassName?: string;
+}) {
const aspectRatio = 94.3941 / 56.96;
return (
);
diff --git a/apps/mobile/src/components/ThemedSwitch.tsx b/apps/mobile/src/components/ThemedSwitch.tsx
index 270ee084e4..5b4603fd12 100644
--- a/apps/mobile/src/components/ThemedSwitch.tsx
+++ b/apps/mobile/src/components/ThemedSwitch.tsx
@@ -1,21 +1,19 @@
import { Platform, Switch, type SwitchProps } from "react-native";
-import { useThemeColor } from "../lib/useThemeColor";
-
export function ThemedSwitch(props: SwitchProps) {
- const activeTrack = String(useThemeColor("--color-switch-active-track"));
- const inactiveTrack = String(useThemeColor("--color-switch-inactive-track"));
- const activeThumb = String(useThemeColor("--color-switch-active-thumb"));
- const inactiveThumb = String(useThemeColor("--color-switch-inactive-thumb"));
-
return (
);
}
diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
index 801862086b..5b61d302a7 100644
--- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
+++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
@@ -27,7 +27,7 @@ import { EmptyState } from "../../components/EmptyState";
import { ProjectFavicon } from "../../components/ProjectFavicon";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { relativeTime } from "../../lib/time";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
import {
createNativeMailSearchToolbarItem,
@@ -70,8 +70,6 @@ function ArchivedThreadsHeader(props: {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const hasCustomFilter = props.selectedEnvironmentId !== null || props.sortOrder !== "newest";
- const searchIconColor = useThemeColor("--color-icon");
- const searchTextColor = useThemeColor("--color-foreground");
const usesNativeChrome = Platform.OS === "ios";
const usesCompactMailToolbar =
Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
@@ -154,7 +152,7 @@ function ArchivedThreadsHeader(props: {
@@ -162,7 +160,7 @@ function ArchivedThreadsHeader(props: {
@@ -402,9 +400,7 @@ function ArchivedThreadRow(props: {
readonly thread: EnvironmentThreadShell;
}) {
const { width: windowWidth } = useWindowDimensions();
- const cardColor = useThemeColor("--color-card");
- const iconColor = useThemeColor("--color-icon-subtle");
- const separatorColor = useThemeColor("--color-separator");
+ const cardColor = useUniwindTheme()["--color-card"];
const timestamp = relativeTime(props.thread.archivedAt ?? props.thread.updatedAt);
const subtitle = [props.environmentLabel, props.thread.branch].filter((part): part is string =>
Boolean(part),
@@ -436,14 +432,15 @@ function ArchivedThreadRow(props: {
>
{() => (
-
+
@@ -463,7 +460,7 @@ function ArchivedThreadRow(props: {
(null);
const archiveScrollGesture = useMemo(() => Gesture.Native(), []);
- const refreshTint = useThemeColor("--color-icon");
const environmentLabelsById = useMemo(
() =>
new Map(
@@ -594,7 +590,7 @@ export function ArchivedThreadsScreen(props: {
if (isInitialLoad) {
return (
-
+
Loading archive...
);
@@ -610,7 +606,7 @@ export function ArchivedThreadsScreen(props: {
title={isFiltered ? "No matching threads" : "No archived threads"}
/>
);
- }, [isFiltered, isInitialLoad, refreshTint]);
+ }, [isFiltered, isInitialLoad]);
return (
@@ -649,7 +645,7 @@ export function ArchivedThreadsScreen(props: {
}
renderItem={renderListItem}
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
index 4d7b086418..42aa8ffebb 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts
@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { EnvironmentId } from "@t3tools/contracts";
import { RelayMobileClientId } from "@t3tools/contracts/relay";
-import { ManagedRelay } from "@t3tools/client-runtime/relay";
+import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay";
import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
import { HttpClient } from "effect/unstable/http";
import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
@@ -34,6 +34,14 @@ vi.mock("expo-constants", () => ({
}));
vi.mock("expo-device", () => ({
+ deviceType: 1,
+ DeviceType: {
+ UNKNOWN: 0,
+ PHONE: 1,
+ TABLET: 2,
+ DESKTOP: 3,
+ TV: 4,
+ },
osVersion: "18.4.1",
modelName: "iPhone 15 Pro",
}));
@@ -1081,13 +1089,88 @@ describe("mobile cloud link environment client", () => {
).pipe(Effect.flip);
expect(error).toMatchObject({
_tag: "CloudEnvironmentLinkError",
- message:
- "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.",
+ message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`,
traceId: "trace-connect",
});
}),
);
+ it.effect(
+ "presents clock skew as one possible cause when an older environment rejects DPoP",
+ () =>
+ Effect.gen(function* () {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn((url: string | URL) => {
+ const value = String(url);
+ if (value.endsWith("/v1/client/dpop-token")) {
+ return Promise.resolve(
+ Response.json(validDpopAccessTokenResponse("environment:connect")),
+ );
+ }
+ if (value.endsWith("/v1/environments/env-1/connect")) {
+ return Promise.resolve(
+ Response.json({
+ environmentId: "env-1",
+ endpoint: {
+ httpBaseUrl: "https://desktop.example.test/",
+ wsBaseUrl: "wss://desktop.example.test/ws",
+ providerKind: "cloudflare_tunnel",
+ },
+ credential: "one-time-cloud-credential",
+ expiresAt: "2026-05-25T00:05:00.000Z",
+ }),
+ );
+ }
+ if (value.endsWith("/.well-known/t3/environment")) {
+ return Promise.resolve(
+ Response.json({
+ environmentId: "env-1",
+ label: "Desktop",
+ platform: { os: "darwin", arch: "arm64" },
+ serverVersion: "0.0.0-test",
+ capabilities: { repositoryIdentity: true },
+ }),
+ );
+ }
+ return Promise.resolve(
+ Response.json(
+ {
+ _tag: "EnvironmentAuthInvalidError",
+ code: "auth_invalid",
+ reason: "invalid_credential",
+ traceId: "trace-environment",
+ },
+ { status: 401 },
+ ),
+ );
+ }),
+ );
+
+ const error = yield* withCloudServices(
+ connectCloudEnvironment({
+ clerkToken: "clerk-token",
+ environment: {
+ environmentId: EnvironmentId.make("env-1"),
+ label: "Desktop",
+ endpoint: {
+ httpBaseUrl: "https://desktop.example.test/",
+ wsBaseUrl: "wss://desktop.example.test/ws",
+ providerKind: "cloudflare_tunnel",
+ },
+ linkedAt: "2026-05-25T00:00:00.000Z",
+ },
+ }),
+ ).pipe(Effect.flip);
+
+ expect(error).toMatchObject({
+ _tag: "CloudEnvironmentLinkError",
+ message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`,
+ traceId: "trace-environment",
+ });
+ }),
+ );
+
it.effect("rejects relay connect responses for a different endpoint", () =>
Effect.gen(function* () {
vi.stubGlobal(
diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts
index 958827ee49..c2033117f6 100644
--- a/apps/mobile/src/features/cloud/linkEnvironment.ts
+++ b/apps/mobile/src/features/cloud/linkEnvironment.ts
@@ -4,6 +4,7 @@ import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import {
EnvironmentCloudEndpointUnavailableError,
+ EnvironmentAuthInvalidError,
EnvironmentHttpBadRequestError,
EnvironmentHttpConflictError,
EnvironmentHttpForbiddenError,
@@ -17,7 +18,6 @@ import {
RelayEnvironmentConnectScope,
RelayEnvironmentStatusScope,
type RelayDpopAccessTokenScope,
- type RelayProtectedError as RelayProtectedErrorType,
type RelayClientEnvironmentRecord,
type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType,
type RelayManagedEndpointProviderKind,
@@ -25,7 +25,11 @@ import {
import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization";
import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
import { findErrorTraceId } from "@t3tools/client-runtime/errors";
-import { ManagedRelay } from "@t3tools/client-runtime/relay";
+import {
+ dpopFailureMessage,
+ ManagedRelay,
+ relayProtectedErrorMessage,
+} from "@t3tools/client-runtime/relay";
import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
import { authClientMetadata } from "../../lib/authClientMetadata";
@@ -73,18 +77,24 @@ const isEnvironmentCloudApiError = Schema.is(
EnvironmentCloudEndpointUnavailableError,
]),
);
+const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError);
const MANAGED_ENDPOINT_PROVIDER_KIND =
"cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind;
-function cloudEnvironmentLinkError(message: string) {
+function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) {
return (cause: unknown) => {
const environmentError = findEnvironmentCloudApiError(cause);
const traceId = findErrorTraceId(cause);
+ const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null;
+ const detail = environmentError
+ ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
+ : withDevCause(message, cause);
return new CloudEnvironmentLinkError({
- message: environmentError
- ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
- : withDevCause(message, cause),
+ message:
+ dpopAuthError?.reason === "invalid_credential"
+ ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason)
+ : detail,
cause,
...(traceId === null ? {} : { traceId }),
});
@@ -117,50 +127,6 @@ function withDevCause(message: string, cause: unknown): string {
return detail ? `${message} (${detail})` : message;
}
-function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
- switch (error._tag) {
- case "RelayAuthInvalidError":
- switch (error.reason) {
- case "missing_bearer":
- case "invalid_bearer":
- return "Relay rejected the cloud session token.";
- case "invalid_dpop":
- return "Relay rejected the DPoP proof.";
- case "not_authorized":
- return "Relay rejected the authenticated request.";
- }
- case "RelayEnvironmentLinkProofExpiredError":
- return "Relay rejected an expired environment link proof.";
- case "RelayEnvironmentLinkProofInvalidError":
- return `Relay rejected the environment link proof (${error.reason}).`;
- case "RelayEnvironmentConnectNotAuthorizedError":
- // "Not authorized" covers non-auth causes too; surface the reason so a
- // missing link doesn't read as a credential problem.
- if (error.reason === "environment_link_not_found") {
- return "Relay has no active link for this environment. The environment server may not have re-established its link yet.";
- }
- return error.reason
- ? `Relay rejected the environment connection request (${error.reason}).`
- : "Relay rejected the environment connection request.";
- case "RelayEnvironmentEndpointUnavailableError":
- return `Relay could not reach the environment endpoint (${error.reason}).`;
- case "RelayEnvironmentEndpointTimedOutError":
- return "Relay timed out while contacting the environment endpoint.";
- case "RelayEnvironmentLinkFailedError":
- return `Relay could not link the environment (${error.reason}).`;
- case "RelayEnvironmentLinkUnavailableError":
- return `Relay cannot provision the managed endpoint (${error.reason}).`;
- case "RelayEnvironmentLinkLimitExceededError":
- return `Relay refused the link: this account already has its maximum of ${error.maxTunnels} managed tunnels. Unlink an environment to free one up.`;
- case "RelayAgentActivityPublishProofExpiredError":
- return "Relay rejected an expired agent activity publish proof.";
- case "RelayAgentActivityPublishProofInvalidError":
- return `Relay rejected the agent activity publish proof (${error.reason}).`;
- case "RelayInternalError":
- return `Relay encountered an internal error (${error.reason}).`;
- }
-}
-
function decodedRelayClientError(message: string) {
return (cause: ManagedRelay.ManagedRelayClientError) => {
const relayError =
@@ -185,6 +151,16 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin
return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
}
+function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null {
+ if (isEnvironmentAuthInvalidError(cause)) {
+ return cause;
+ }
+ if (typeof cause !== "object" || cause === null) {
+ return null;
+ }
+ return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null;
+}
+
function requireRelayUrl(): Effect.Effect {
const relayUrl = readRelayUrl();
return relayUrl
@@ -560,7 +536,9 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag
clientMetadata: authClientMetadata(),
}).pipe(
Effect.mapError(
- cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."),
+ cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", {
+ dpop: true,
+ }),
),
);
const pairingUrl = new URL(connect.endpoint.httpBaseUrl);
diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
index 6da73eaeb1..4c840636c9 100644
--- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
+++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
@@ -18,7 +18,6 @@ import { AppText as Text } from "../../components/AppText";
import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
-import { useThemeColor } from "../../lib/useThemeColor";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
import { hasCloudPublicConfig } from "../cloud/publicConfig";
@@ -78,7 +77,6 @@ function CloudEnvironmentRowsContent(
props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean },
) {
const controller = useConnectionController();
- const iconColor = useThemeColor("--color-icon");
const discoveryAvailable = props.discoveryAvailable ?? true;
const availableCloudEnvironments = discoveryAvailable
? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments)
@@ -118,12 +116,12 @@ function CloudEnvironmentRowsContent(
className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
>
{controller.relayDiscovery.isRefreshing ? (
-
+
) : (
)}
@@ -158,7 +156,7 @@ function CloudEnvironmentRowsContent(
) : controller.relayDiscovery.isRefreshing ? (
-
+
Loading linked cloud environments.
@@ -275,7 +273,6 @@ function CloudEnvironmentRowShell(props: {
readonly statusText?: string;
readonly value: boolean;
}) {
- const chevron = useThemeColor("--color-chevron");
const isRetrying =
props.connectionState === "connecting" || props.connectionState === "reconnecting";
const shouldPulse = isRetrying;
@@ -287,7 +284,7 @@ function CloudEnvironmentRowShell(props: {
traceId: props.connectionErrorTraceId,
});
const statusClassName = props.connectionError
- ? "text-rose-500 dark:text-rose-400"
+ ? "text-adaptive-rose-500-400"
: "text-foreground-muted";
const [errorMeasurement, setErrorMeasurement] = useState<{
readonly text: string;
@@ -377,7 +374,7 @@ function CloudEnvironmentRowShell(props: {
-
+
Copy trace ID
);
diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
index 03a0eb5025..86ebc6c11a 100644
--- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
+++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
@@ -7,7 +7,6 @@ import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useState } from "react";
import { Alert, Pressable, View } from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
-import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { cn } from "../../lib/cn";
@@ -36,10 +35,6 @@ export function ConnectionEnvironmentRow(props: {
}) {
const [label, setLabel] = useState(props.environment.environmentLabel);
const [url, setUrl] = useState(props.environment.displayUrl);
-
- const mutedColor = useThemeColor("--color-icon-subtle");
- const primaryFg = useThemeColor("--color-primary-foreground");
- const dangerFg = useThemeColor("--color-danger-foreground");
const statusLabel = connectionStatusLabel(props.environment);
const statusTraceId = props.environment.connectionErrorTraceId;
const hasConnectionFailure = props.environment.connectionError !== null;
@@ -85,7 +80,7 @@ export function ConnectionEnvironmentRow(props: {
-
+
Save
@@ -188,7 +188,7 @@ export function ConnectionEnvironmentRow(props: {
@@ -197,7 +197,12 @@ export function ConnectionEnvironmentRow(props: {
className="h-[42px] w-[42px] items-center justify-center rounded-[14px] border border-danger-border bg-danger active:opacity-70"
onPress={() => props.onRemove(props.environment.environmentId)}
>
-
+
diff --git a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
index f88e328744..fe26c66a35 100644
--- a/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
+++ b/apps/mobile/src/features/connection/ConnectionSheetButton.tsx
@@ -1,6 +1,5 @@
import { SymbolView } from "../../components/AppSymbol";
import { Platform, Pressable } from "react-native";
-import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text } from "../../components/AppText";
import { cn } from "../../lib/cn";
@@ -37,11 +36,12 @@ export function ConnectionSheetButton(props: {
}) {
const tone = props.tone ?? "secondary";
- const primaryFg = useThemeColor("--color-primary-foreground");
- const dangerFg = useThemeColor("--color-danger-foreground");
- const secondaryFg = useThemeColor("--color-secondary-foreground");
-
- const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg;
+ const textColorClassName =
+ tone === "primary"
+ ? "accent-primary-foreground"
+ : tone === "danger"
+ ? "accent-danger-foreground"
+ : "accent-secondary-foreground";
const primaryShadow =
tone === "primary"
@@ -79,7 +79,7 @@ export function ConnectionSheetButton(props: {
(null);
- const headerIconColor = useThemeColor("--color-icon");
+ const headerIconColor = useUniwindTheme()["--color-icon"];
const connectDisabled = isSubmitting || hostInput.trim().length === 0;
diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx
index 464477ffc8..88d4e2d4be 100644
--- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx
+++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx
@@ -5,7 +5,6 @@ import type { EnvironmentId } from "@t3tools/contracts";
import { useCallback, useState } from "react";
import { Platform, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useThemeColor } from "../../lib/useThemeColor";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text } from "../../components/AppText";
@@ -24,9 +23,6 @@ export function ConnectionsRouteScreen() {
const insets = useSafeAreaInsets();
const hasEnvironments = connectedEnvironments.length > 0;
const [expandedId, setExpandedId] = useState(null);
-
- const accentColor = useThemeColor("--color-icon-muted");
-
const handleToggle = useCallback((environmentId: EnvironmentId) => {
setExpandedId((prev) => (prev === environmentId ? null : environmentId));
}, []);
@@ -89,7 +85,7 @@ export function ConnectionsRouteScreen() {
diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx
index ce7e7bec96..4bb15fc987 100644
--- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx
+++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx
@@ -7,7 +7,6 @@ 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) {
@@ -55,7 +54,6 @@ export function EnvironmentConnectionNotice(props: {
readonly resourceName: string;
readonly onRetry: () => void;
}) {
- const iconColor = String(useThemeColor("--color-icon-muted"));
const isRetrying =
props.connection.phase === "connecting" || props.connection.phase === "reconnecting";
@@ -63,12 +61,12 @@ export function EnvironmentConnectionNotice(props: {
{isRetrying ? (
-
+
) : (
)}
diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts
index 0de49ceabf..51ee592c2c 100644
--- a/apps/mobile/src/features/connection/connectionTone.ts
+++ b/apps/mobile/src/features/connection/connectionTone.ts
@@ -6,38 +6,38 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone {
case "connected":
return {
label: "Connected",
- pillClassName: "bg-emerald-500/12 dark:bg-emerald-500/16",
- textClassName: "text-emerald-700 dark:text-emerald-300",
+ pillClassName: "bg-adaptive-emerald-500-a12-a16",
+ textClassName: "text-adaptive-emerald-700-300",
};
case "reconnecting":
return {
label: "Reconnecting",
- pillClassName: "bg-amber-500/12 dark:bg-amber-500/16",
- textClassName: "text-amber-700 dark:text-amber-300",
+ pillClassName: "bg-adaptive-amber-500-a12-a16",
+ textClassName: "text-adaptive-amber-700-300",
};
case "connecting":
return {
label: "Connecting",
- pillClassName: "bg-sky-500/12 dark:bg-sky-500/16",
- textClassName: "text-sky-700 dark:text-sky-300",
+ pillClassName: "bg-adaptive-sky-500-a12-a16",
+ textClassName: "text-adaptive-sky-700-300",
};
case "error":
return {
label: "Connection failed",
- pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
- textClassName: "text-rose-700 dark:text-rose-300",
+ pillClassName: "bg-adaptive-rose-500-a12-a16",
+ textClassName: "text-adaptive-rose-700-300",
};
case "offline":
return {
label: "Offline",
- pillClassName: "bg-rose-500/12 dark:bg-rose-500/16",
- textClassName: "text-rose-700 dark:text-rose-300",
+ pillClassName: "bg-adaptive-rose-500-a12-a16",
+ textClassName: "text-adaptive-rose-700-300",
};
case "available":
return {
label: "Available",
- pillClassName: "bg-neutral-500/10 dark:bg-neutral-500/16",
- textClassName: "text-neutral-600 dark:text-neutral-300",
+ pillClassName: "bg-adaptive-neutral-500-a10-a16",
+ textClassName: "text-adaptive-neutral-600-300",
};
}
}
diff --git a/apps/mobile/src/features/files/FileMarkdownPreview.tsx b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
index c9788d906f..4c581d7dba 100644
--- a/apps/mobile/src/features/files/FileMarkdownPreview.tsx
+++ b/apps/mobile/src/features/files/FileMarkdownPreview.tsx
@@ -14,7 +14,7 @@ import {
resolveMarkdownFontSizes,
resolveNativeMarkdownTypography,
} from "../../lib/appearancePreferences";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import {
hasNativeSelectableMarkdownText,
@@ -40,14 +40,15 @@ function useMarkdownPreviewStyles(): MarkdownPreviewStyles {
() => resolveNativeMarkdownTypography(appearance.baseFontSize),
[appearance.baseFontSize],
);
- const body = String(useThemeColor("--color-md-body"));
- const strong = String(useThemeColor("--color-md-strong"));
- const link = String(useThemeColor("--color-md-link"));
- const blockquoteBorder = String(useThemeColor("--color-md-blockquote-border"));
- const blockquoteBackground = String(useThemeColor("--color-md-blockquote-bg"));
- const codeBackground = String(useThemeColor("--color-md-code-bg"));
- const codeText = String(useThemeColor("--color-md-code-text"));
- const horizontalRule = String(useThemeColor("--color-md-hr"));
+ const theme = useUniwindTheme();
+ const body = theme["--color-md-body"];
+ const strong = theme["--color-md-strong"];
+ const link = theme["--color-md-link"];
+ const blockquoteBorder = theme["--color-md-blockquote-border"];
+ const blockquoteBackground = theme["--color-md-blockquote-bg"];
+ const codeBackground = theme["--color-md-code-bg"];
+ const codeText = theme["--color-md-code-text"];
+ const horizontalRule = theme["--color-md-hr"];
const regularFontFamily = useFontFamily("regular");
const mediumFontFamily = useFontFamily("medium");
const boldFontFamily = useFontFamily("bold");
diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx
index f89bea1330..bce58d838a 100644
--- a/apps/mobile/src/features/files/FileTreeBrowser.tsx
+++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx
@@ -7,7 +7,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
import { PierreEntryIcon } from "../../components/PierreEntryIcon";
import { cn } from "../../lib/cn";
-import { useThemeColor } from "../../lib/useThemeColor";
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import {
@@ -46,7 +45,6 @@ const FileTreeRow = memo(function FileTreeRow(props: {
readonly item: VisibleFileTreeNode;
readonly selected: boolean;
readonly expanded: boolean;
- readonly iconColor: string;
readonly onPressDirectory: (path: string) => void;
readonly onPreviewFile?: (path: string) => void;
readonly onPressFile: (path: string) => void;
@@ -79,7 +77,7 @@ const FileTreeRow = memo(function FileTreeRow(props: {
) : (
@@ -125,7 +123,6 @@ export function FileTreeBrowser(props: {
// Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the
// observed adjustedContentInset bottom (~102) seen in the native trace.
const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0;
- const iconColor = String(useThemeColor("--color-icon-muted"));
const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props;
const controlledSelectedPathRef = useRef(controlledSelectedPath);
const pendingSelectionTimeoutRef = useRef | null>(null);
@@ -216,13 +213,12 @@ export function FileTreeBrowser(props: {
item={item}
selected={item.node.kind === "file" && item.node.path === selectedPath}
expanded={expandedPaths.has(item.node.path)}
- iconColor={iconColor}
onPressDirectory={toggleDirectory}
onPreviewFile={onPreviewFile}
onPressFile={handleSelectFile}
/>
),
- [expandedPaths, handleSelectFile, iconColor, onPreviewFile, selectedPath, toggleDirectory],
+ [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory],
);
if (props.error && props.entries.length === 0) {
diff --git a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx
index 09e1810617..0035dcc12a 100644
--- a/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx
+++ b/apps/mobile/src/features/files/MarkdownFrontmatterTable.tsx
@@ -2,18 +2,10 @@ import type { MarkdownFrontmatterEntry } from "@t3tools/client-runtime/markdown-
import { useState } from "react";
import { ScrollView, Text as NativeText, View } from "react-native";
-import { useThemeColor } from "../../lib/useThemeColor";
-
const MIN_KEY_COLUMN_WIDTH = 160;
const MIN_VALUE_COLUMN_WIDTH = 400;
-function MarkdownFrontmatterList({
- items,
- textColor,
-}: {
- readonly items: ReadonlyArray;
- readonly textColor: string;
-}) {
+function MarkdownFrontmatterList({ items }: { readonly items: ReadonlyArray }) {
const occurrences = new Map();
return (
@@ -27,9 +19,7 @@ function MarkdownFrontmatterList({
key={JSON.stringify([item, occurrence])}
className="max-w-full rounded-full border border-secondary-border bg-secondary px-2.5 py-1"
>
-
- {item}
-
+ {item}
);
})}
@@ -42,9 +32,6 @@ export function MarkdownFrontmatterTable({
}: {
readonly entries: ReadonlyArray;
}) {
- const textColor = String(useThemeColor("--color-md-body"));
- const strongColor = String(useThemeColor("--color-md-strong"));
- const codeColor = String(useThemeColor("--color-md-code-text"));
const [keyWidths, setKeyWidths] = useState>(() => new Map());
let measuredKeyColumnWidth = MIN_KEY_COLUMN_WIDTH;
let hasEveryKeyWidth = true;
@@ -67,7 +54,9 @@ export function MarkdownFrontmatterTable({
>
{entries.map((entry, index) => (
-
+
{entry.key}
{entry.value.kind === "text" ? (
-
+
{entry.value.text}
) : entry.value.kind === "list" ? (
-
+
) : (
-
+
{entry.value.source}
)}
diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx
index 942d0b4ffb..2eabce998e 100644
--- a/apps/mobile/src/features/files/SourceFileSurface.tsx
+++ b/apps/mobile/src/features/files/SourceFileSurface.tsx
@@ -17,6 +17,7 @@ import { cn } from "../../lib/cn";
import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import {
buildNativeSourceTokens,
NATIVE_SOURCE_CONTENT_WIDTH,
@@ -153,6 +154,7 @@ function NativeSourceFileSurface(
const { NativeView, onRefresh } = props;
const { codeSurface, codeWordBreak, nativeSourceStyle } = useAppearanceCodeSurface();
const { themeAppearance, themeId } = useAppearancePreferences();
+ const appTheme = useUniwindTheme();
const { width: viewportWidth } = useWindowDimensions();
const { rowsJson, status, targetIndex, tokens } = useSourceFileModel(props);
const [isPullRefreshing, setIsPullRefreshing] = useState(false);
@@ -173,8 +175,8 @@ function NativeSourceFileSurface(
[targetIndex],
);
const themeJson = useMemo(
- () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId)),
- [themeAppearance, themeId],
+ () => JSON.stringify(createNativeReviewDiffTheme(themeAppearance, themeId, appTheme)),
+ [appTheme, themeAppearance, themeId],
);
const styleJson = useMemo(() => JSON.stringify(nativeSourceStyle), [nativeSourceStyle]);
const contentWidth = codeWordBreak
diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index 28356be185..462d075324 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -1,6 +1,7 @@
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
-import { useCallback, useEffect, useRef, useState } from "react";
+import type { MenuAction } from "@react-native-menu/menu";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
@@ -11,15 +12,16 @@ import {
ThreadId,
} from "@t3tools/contracts";
-import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
+import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import { ControlPillMenu } from "../../components/ControlPill";
import { EmptyState } from "../../components/EmptyState";
import { LoadingScreen } from "../../components/LoadingScreen";
import { resolveFileSelectionNavigationAction } from "../../lib/adaptive-navigation";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useThreadSelection } from "../../state/use-thread-selection";
import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree";
import { useEnvironmentQuery } from "../../state/query";
@@ -135,11 +137,11 @@ function FileContent(props: {
return (
{props.truncated ? (
-
-
+
+
Partial file
-
+
Preview limited to the first 1 MB of a truncated file.
@@ -210,7 +212,7 @@ function FilesUnavailable() {
}
function FilesToolbarBottomFade() {
- const sheetColor = String(useThemeColor("--color-sheet"));
+ const sheetColor = String(useUniwindTheme()["--color-sheet"]);
if (process.env.EXPO_OS !== "ios") {
return null;
@@ -245,8 +247,8 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
const [searchQuery, setSearchQuery] = useState("");
const isAndroid = Platform.OS === "android";
const { themeAppearance: highlightTheme } = useAppearancePreferences();
- const iconColor = String(useThemeColor("--color-icon-muted"));
- const sheetSurfaceColor = String(useThemeColor("--color-sheet-solid"));
+ const theme = useUniwindTheme();
+ const sheetSurfaceColor = theme["--color-sheet-solid"];
const { cwd, environmentId, projectName, selectedThread, threadId } = useThreadFilesWorkspace(
props.route.params,
);
@@ -413,7 +415,12 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) {
]}
/>
-
+
{
+ if (relativePath === null) return [];
+ const canToggleMode = canPreview && !isImageFile;
+ return [
+ canToggleMode
+ ? ({
+ id: "preview",
+ title: "Preview",
+ icon: "eye",
+ inline: true,
+ onPress: () => setModeOverride({ path: relativePath, mode: "preview" }),
+ } as const)
+ : null,
+ canToggleMode
+ ? ({
+ id: "source",
+ title: "Source",
+ icon: "doc.text",
+ inline: true,
+ onPress: () => setModeOverride({ path: relativePath, mode: "source" }),
+ } as const)
+ : null,
+ {
+ id: "copy-path",
+ title: "Copy path",
+ icon: "doc.on.doc",
+ inline: false,
+ onPress: () => copyTextWithHaptic(relativePath),
+ } as const,
+ isBrowserFile && typeof assetPreviewUri === "string"
+ ? ({
+ id: "open-browser",
+ title: Platform.OS === "ios" ? "Open in Safari" : "Open in browser",
+ icon: "safari",
+ inline: false,
+ onPress: () => tryOpenExternalUrl(assetPreviewUri, "file-preview"),
+ } as const)
+ : null,
+ resolvedActiveMode === "preview" && (isBrowserFile || isImageFile)
+ ? ({
+ id: "refresh",
+ title: "Refresh",
+ icon: "arrow.clockwise",
+ inline: false,
+ onPress: () => setPreviewRevision((current) => current + 1),
+ } as const)
+ : null,
+ ].filter((action) => action !== null);
+ }, [assetPreviewUri, canPreview, isBrowserFile, isImageFile, relativePath, resolvedActiveMode]);
+
+ const androidFileMenuActions = useMemo(
+ () =>
+ fileMenuActions.map((action) => ({
+ id: action.id,
+ title: action.title,
+ image: action.icon,
+ state: action.id === resolvedActiveMode ? "on" : undefined,
+ })),
+ [fileMenuActions, resolvedActiveMode],
+ );
+ const handleAndroidFileMenuAction = useCallback(
+ (event: { nativeEvent: { event: string } }) => {
+ const action = fileMenuActions.find(({ id }) => id === event.nativeEvent.event);
+ void action?.onPress();
+ },
+ [fileMenuActions],
+ );
+ const handleReturnToThread = useCallback(() => {
+ if (environmentId !== null && threadId !== null) {
+ navigation.dispatch(
+ StackActions.replace("Thread", {
+ environmentId: String(environmentId),
+ threadId: String(threadId),
+ }),
+ );
+ }
+ }, [environmentId, navigation, threadId]);
+ const handleBack = useCallback(() => {
+ if (navigation.canGoBack()) {
+ navigation.goBack();
+ return;
+ }
+ handleReturnToThread();
+ }, [handleReturnToThread, navigation]);
+
if (selectedThread === null || environmentId === null || threadId === null) {
return ;
}
@@ -577,6 +670,7 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
// Static header config lives in Stack.tsx (SOLID_HEADER_OPTIONS: solid
// sheet-colored header — this route's content scrolls internally, so
// there is nothing for glass to sample). Only dynamic values here.
+ headerShown: !isAndroid,
headerTintColor: iconColor,
headerTitle: basename(relativePath),
title: basename(relativePath),
@@ -584,19 +678,40 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
Platform.OS === "ios" && headerSubtitle.length > 0 ? headerSubtitle : undefined,
}}
/>
+ {isAndroid ? (
+
+ {fileInspector.supported ? (
+
+ ) : null}
+
+
+
+ >
+ }
+ />
+ ) : null}
{fileInspector.supported ? (
{
- navigation.dispatch(
- StackActions.replace("Thread", {
- environmentId: String(environmentId),
- threadId: String(threadId),
- }),
- );
- }}
+ onPress={handleReturnToThread}
/>
) : null}
@@ -612,50 +727,33 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
/>
) : null}
- {canPreview && !isImageFile ? (
+ {fileMenuActions.some(({ inline }) => inline) ? (
+ {fileMenuActions
+ .filter(({ inline }) => inline)
+ .map((action) => (
+
+ {action.title}
+
+ ))}
+
+ ) : null}
+ {fileMenuActions
+ .filter(({ inline }) => !inline)
+ .map((action) => (
setModeOverride({ path: relativePath, mode: "preview" })}
- >
- Preview
-
- setModeOverride({ path: relativePath, mode: "source" })}
+ key={action.id}
+ icon={action.icon}
+ onPress={action.onPress}
>
- Source
+ {action.title}
-
- ) : null}
- copyTextWithHaptic(relativePath)}
- >
- Copy path
-
- {isBrowserFile && typeof assetPreviewUri === "string" ? (
- {
- void tryOpenExternalUrl(assetPreviewUri, "file-preview");
- }}
- >
- Open in Safari
-
- ) : null}
- {resolvedActiveMode === "preview" && (isBrowserFile || isImageFile) ? (
- {
- setPreviewRevision((current) => current + 1);
- }}
- >
- Refresh
-
- ) : null}
+ ))}
-
+
-
+
{props.children}
@@ -42,7 +39,7 @@ function AndroidHomeFab(props: {
diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx
index e7ce41cb43..cbeadf59f6 100644
--- a/apps/mobile/src/features/home/HomeHeader.tsx
+++ b/apps/mobile/src/features/home/HomeHeader.tsx
@@ -12,7 +12,7 @@ import { SymbolView } from "../../components/AppSymbol";
import { T3Wordmark } from "../../components/T3Wordmark";
import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
import { resolveMobileStageLabel } from "../../lib/mobileBranding";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
@@ -67,8 +67,6 @@ function checkedMenuState(checked: boolean) {
function AndroidHomeHeader(props: HomeHeaderProps) {
const insets = useSafeAreaInsets();
- const iconColor = useThemeColor("--color-icon");
- const mutedColor = useThemeColor("--color-foreground-muted");
const stageLabel = resolveMobileStageLabel(Constants.expoConfig?.extra?.appVariant);
// Thread List v2 lays the list out in fixed creation order, so the
// sort/group filter controls would be silently ignored — hide them and
@@ -218,7 +216,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
brand={
{/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */}
-
+
Code
@@ -248,7 +246,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
: "line.3.horizontal.decrease.circle"
}
size={16}
- tintColor={iconColor}
+ tintColorClassName={"accent-icon"}
type="monochrome"
/>
@@ -262,12 +260,22 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
onPress={props.onOpenSettings}
className="size-11 items-center justify-center rounded-full bg-subtle"
>
-
+
-
+
@@ -300,7 +308,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) {
function IosHomeHeader(props: HomeHeaderProps) {
const searchBarRef = useRef(null);
- const iconColor = useThemeColor("--color-icon");
+ const iconColor = useUniwindTheme()["--color-icon"];
// Thread List v2 lays the list out in fixed creation order, so the
// sort/group filter controls would be silently ignored — hide them and
// key the "customized" icon state off the environment filter alone.
diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 3a92131bb2..57cbb9125e 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -23,7 +23,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useThemeColor } from "../../lib/useThemeColor";
import { AppText as Text } from "../../components/AppText";
import { EmptyState } from "../../components/EmptyState";
@@ -216,7 +215,6 @@ export function HomeScreen(props: HomeScreenProps) {
const openSwipeableRef = useRef(null);
const listRef = useRef(null);
const insets = useSafeAreaInsets();
- const accentColor = useThemeColor("--color-icon-muted");
const iosBottomToolbarClearance =
Platform.OS === "ios" && !NATIVE_LIQUID_GLASS_SUPPORTED
? PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT
@@ -1088,7 +1086,7 @@ export function HomeScreen(props: HomeScreenProps) {
/>
{emptyState.loading ? (
-
+
) : null}
diff --git a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
index 1867042988..d4aaeb4de4 100644
--- a/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
+++ b/apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx
@@ -8,7 +8,6 @@ import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-na
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle";
-import { useThemeColor } from "../../lib/useThemeColor";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { useWorkspaceState } from "../../state/workspace";
import {
@@ -98,7 +97,6 @@ export function WorkspaceConnectionTitle(props: {
/** Horizontal correction so the status aligns with the brand in native title slots. */
readonly statusOffset?: number;
}) {
- const iconColor = String(useThemeColor("--color-icon-muted"));
const status = useDelayedConnectionStatus();
const size = props.size ?? "navbar";
@@ -125,12 +123,12 @@ export function WorkspaceConnectionTitle(props: {
style={{ marginLeft: props.statusOffset ?? 0 }}
>
{status.showsProgress ? (
-
+
) : (
)}
diff --git a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx
index 66ce2a0aaf..6982b5cb4c 100644
--- a/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx
+++ b/apps/mobile/src/features/layout/WorkspaceEmptyDetail.tsx
@@ -2,15 +2,17 @@ import { SymbolView } from "../../components/AppSymbol";
import { Pressable, View } from "react-native";
import { AppText as Text } from "../../components/AppText";
-import { useThemeColor } from "../../lib/useThemeColor";
export function WorkspaceEmptyDetail(props: { readonly onStartNewTask?: () => void }) {
- const iconColor = useThemeColor("--color-icon-subtle");
-
return (
-
+
Select a thread
Choose a thread from the sidebar or start a new task.
diff --git a/apps/mobile/src/features/layout/workspace-pane-divider.tsx b/apps/mobile/src/features/layout/workspace-pane-divider.tsx
index d476452efa..6396628226 100644
--- a/apps/mobile/src/features/layout/workspace-pane-divider.tsx
+++ b/apps/mobile/src/features/layout/workspace-pane-divider.tsx
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
import { Pressable, StyleSheet, View, type AccessibilityActionEvent } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { runOnJS } from "react-native-reanimated";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { cn } from "../../lib/cn";
const ACCESSIBILITY_RESIZE_STEP = 24;
@@ -22,8 +22,6 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
latestProps.current = props;
const [hovered, setHovered] = useState(false);
const [dragging, setDragging] = useState(false);
- const dividerColor = useThemeColor("--color-border");
- const activeDividerColor = useThemeColor("--color-primary");
const handleResizeStart = useCallback(() => {
setDragging(true);
latestProps.current.onResizeStart?.();
@@ -81,11 +79,11 @@ export function WorkspacePaneDivider(props: WorkspacePaneDividerProps) {
onHoverOut={() => setHovered(false)}
>
@@ -96,11 +94,9 @@ const styles = StyleSheet.create({
line: {
alignSelf: "center",
height: "100%",
- opacity: 0.7,
width: StyleSheet.hairlineWidth,
},
activeLine: {
- opacity: 1,
width: 2,
},
});
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index b48c7a0bdd..a82f693737 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -51,7 +51,6 @@ 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 { useAtomCommand } from "../../state/use-atom-command";
import { useAtomQueryRunner } from "../../state/use-atom-query-runner";
@@ -159,8 +158,6 @@ function ListRow(props: {
readonly right?: ReactNode;
readonly onPress?: () => void;
}) {
- const chevronColor = useThemeColor("--color-chevron");
-
return (
+
) : null}
@@ -205,8 +207,6 @@ function PrimaryActionButton(props: {
readonly loading?: boolean;
readonly onPress: () => void;
}) {
- const primaryForeground = useThemeColor("--color-primary-foreground");
-
return (
{props.loading ? (
-
+
) : (
{props.label}
)}
@@ -414,7 +414,6 @@ function SourceControlRow(props: {
readonly isFirst: boolean;
}) {
const navigation = useNavigation();
- const iconColor = useThemeColor("--color-icon");
const title =
props.source === "url" ? "Git URL" : `${addProjectRemoteSourceLabel(props.source)} repository`;
const subtitle =
@@ -423,9 +422,9 @@ function SourceControlRow(props: {
: `Clone ${addProjectRemoteSourceLabel(props.source)} ${props.hint}`;
const icon =
props.source === "url" ? (
-
+
) : (
-
+
);
if (!props.ready) {
@@ -454,8 +453,6 @@ function SourceControlRow(props: {
export function AddProjectSourceScreen() {
const navigation = useNavigation();
- const accentColor = useThemeColor("--color-icon-muted");
- const iconColor = useThemeColor("--color-icon");
const { environmentOptions, selectedEnvironment, setSelectedEnvironmentId } =
useSelectedEnvironment();
const discoveryState = useEnvironmentQuery(
@@ -496,7 +493,7 @@ export function AddProjectSourceScreen() {
}
@@ -508,7 +505,7 @@ export function AddProjectSourceScreen() {
) : null
@@ -530,7 +527,7 @@ export function AddProjectSourceScreen() {
}
@@ -560,7 +557,9 @@ export function AddProjectSourceScreen() {
),
)}
- {discoveryState.isPending ? : null}
+ {discoveryState.isPending ? (
+
+ ) : null}
>
) : null}
@@ -745,7 +744,6 @@ function FolderBrowser(props: {
}) => Promise;
readonly pinnedDirectoryName?: string;
}) {
- const accentColor = useThemeColor("--color-icon-muted");
const browsePath = useMemo(
() => getFilesystemBrowsePath(props.pathInput, props.environment.platform),
[props.environment.platform, props.pathInput],
@@ -781,7 +779,7 @@ function FolderBrowser(props: {
{browseState.isPending && browseState.data === null ? (
-
+
) : null}
{browsePath.canBrowseUp ? (
@@ -791,7 +789,7 @@ function FolderBrowser(props: {
}
@@ -810,7 +808,14 @@ function FolderBrowser(props: {
}
+ icon={
+
+ }
isFirst={index === 0 && !browsePath.canBrowseUp}
right={null}
onPress={() => {
diff --git a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
index 40f8fcf153..a798e5ebe4 100644
--- a/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
+++ b/apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx
@@ -14,7 +14,6 @@ import { ControlPill } from "../../components/ControlPill";
import { cn } from "../../lib/cn";
import type { DraftComposerImageAttachment } from "../../lib/composerImages";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
-import { useThemeColor } from "../../lib/useThemeColor";
import { useNativePaste } from "../../lib/useNativePaste";
import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
import { appendReviewCommentToDraft } from "../../state/use-thread-composer-state";
@@ -46,7 +45,6 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const { themeAppearance: selectedTheme } = useAppearancePreferences();
- const iconTint = String(useThemeColor("--color-icon"));
const target = useReviewCommentTarget();
const { codeSurface } = useAppearanceCodeSurface();
const { environmentId, threadId } = props.route.params;
@@ -168,7 +166,12 @@ export function ReviewCommentComposerSheet(props: ReviewCommentComposerSheetProp
className="bg-subtle h-12 w-12 items-center justify-center rounded-full"
onPress={dismissComposer}
>
-
+
Add Comment
diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx
index 0524371738..80ebe1157d 100644
--- a/apps/mobile/src/features/review/ReviewSheet.tsx
+++ b/apps/mobile/src/features/review/ReviewSheet.tsx
@@ -37,7 +37,7 @@ import { ControlPillMenu } from "../../components/ControlPill";
import { environmentCatalog } from "../../connection/catalog";
import { useEnvironmentPresentation } from "../../state/presentation";
import { useAtomCommand } from "../../state/use-atom-command";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
import { useThreadDraftForThread } from "../../state/use-thread-composer-state";
import { EnvironmentConnectionNotice } from "../connection/EnvironmentConnectionNotice";
@@ -80,13 +80,11 @@ const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) {
return (
-
-
+
+
Partial diff
-
- {props.notice}
-
+ {props.notice}
);
});
@@ -97,7 +95,6 @@ function ReviewSelectionActionBar(props: {
readonly onOpenComment: (() => void) | null;
readonly onClear: () => void;
}) {
- const foreground = useThemeColor("--color-primary-foreground");
if (!props.title) {
return null;
}
@@ -107,7 +104,7 @@ function ReviewSelectionActionBar(props: {
{props.title}
@@ -144,7 +141,12 @@ function ReviewSelectionActionBar(props: {
className="h-12 w-12 items-center justify-center rounded-full bg-primary"
onPress={props.onClear}
>
-
+
);
@@ -217,8 +219,9 @@ function ReviewFileNavigator({
ref,
}: ReviewFileNavigatorProps) {
const insets = useSafeAreaInsets();
- const sheetColor = String(useThemeColor("--color-sheet"));
- const foregroundColor = String(useThemeColor("--color-foreground"));
+ const theme = useUniwindTheme();
+ const sheetColor = theme["--color-sheet"];
+ const foregroundColor = theme["--color-foreground"];
const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
const [fileSelection, setFileSelection] = useState<{
readonly sectionId: string | null;
@@ -348,7 +351,7 @@ export function ReviewSheet(props: ReviewSheetProps) {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const { themeAppearance: selectedTheme } = useAppearancePreferences();
- const headerIcon = String(useThemeColor("--color-icon"));
+ const headerIcon = String(useUniwindTheme()["--color-icon"]);
const { environmentId, threadId } = props.route.params;
const environment = useEnvironmentPresentation(environmentId);
const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, "environment retry");
diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
index dbd1d7aeb0..3a291b2e78 100644
--- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
+++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vite-plus/test";
-import { MOBILE_THEME_IDS } from "../../lib/mobileTheme";
+import {
+ DEFAULT_MOBILE_THEME_ID,
+ getMobileThemeVariables,
+ MOBILE_THEME_IDS,
+ type MobileThemeAppearance,
+ type MobileThemeId,
+} from "../../lib/mobileTheme";
+import { readDefaultMobileThemeVariables } from "../../lib/mobileTheme.test-support";
import {
createNativeReviewDiffTheme,
@@ -39,6 +46,12 @@ function buildInput(comments: BuildNativeReviewDiffDataInput["comments"]) {
return { parsedDiff, comments } satisfies BuildNativeReviewDiffDataInput;
}
+function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) {
+ return themeId === DEFAULT_MOBILE_THEME_ID
+ ? readDefaultMobileThemeVariables(appearance)
+ : getMobileThemeVariables(themeId, appearance);
+}
+
describe("getCachedNativeReviewDiffData", () => {
it("reuses the row model for equivalent empty comment arrays", () => {
const first = getCachedNativeReviewDiffData(buildInput([]));
@@ -61,7 +74,11 @@ describe("createNativeReviewDiffTheme", () => {
it("serializes every native color as cross-platform opaque hex", () => {
for (const themeId of MOBILE_THEME_IDS) {
for (const appearance of ["light", "dark"] as const) {
- const theme = createNativeReviewDiffTheme(appearance, themeId);
+ const theme = createNativeReviewDiffTheme(
+ appearance,
+ themeId,
+ appTheme(themeId, appearance),
+ );
for (const color of Object.values(theme)) {
expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i);
}
@@ -70,8 +87,8 @@ describe("createNativeReviewDiffTheme", () => {
});
it("uses the selected app palette for native code surfaces", () => {
- const standard = createNativeReviewDiffTheme("dark", "t3-code");
- const iris = createNativeReviewDiffTheme("dark", "iris");
+ const standard = createNativeReviewDiffTheme("dark", "t3-code", appTheme("t3-code", "dark"));
+ const iris = createNativeReviewDiffTheme("dark", "iris", appTheme("iris", "dark"));
expect(iris.background).not.toBe(standard.background);
expect(iris.hunkText).not.toBe(standard.hunkText);
diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
index 66beae22e9..a45a955d33 100644
--- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
+++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts
@@ -8,11 +8,7 @@ import { pipe } from "effect/Function";
import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
import { MOBILE_CODE_SURFACE } from "../../lib/typography";
-import {
- DEFAULT_MOBILE_THEME_ID,
- getMobileThemeVariables,
- type MobileThemeId,
-} from "../../lib/mobileTheme";
+import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme";
import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
import { computeWordAltDiffRanges } from "./reviewWordDiffs";
import {
@@ -137,10 +133,10 @@ function buildReviewCommentsCacheKey(comments: ReadonlyArray>(
() => new Set(),
);
- const theme = useMemo(() => createNativeReviewDiffTheme(scheme, themeId), [scheme, themeId]);
+ const theme = useMemo(
+ () => createNativeReviewDiffTheme(scheme, themeId, appTheme),
+ [appTheme, scheme, themeId],
+ );
const rowsJson = useMemo(() => JSON.stringify(data.rows), [data.rows]);
const collapsedFileIdsJson = useMemo(() => JSON.stringify(collapsedFileIds), [collapsedFileIds]);
const viewedFileIdsJson = useMemo(() => JSON.stringify(viewedFileIds), [viewedFileIds]);
diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx
index 9e18d4675f..3480340f40 100644
--- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx
@@ -1,12 +1,11 @@
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
-import { SymbolView } from "expo-symbols";
import { useMemo } from "react";
import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { SymbolView } from "../../components/AppSymbol";
import {
clearClientCacheAtom,
clientCacheSummaryAtom,
@@ -17,8 +16,6 @@ import { SettingsSection } from "./components/SettingsSection";
export function SettingsClientStorageRouteScreen() {
const insets = useSafeAreaInsets();
- const iconColor = useThemeColor("--color-icon");
- const dangerForegroundColor = useThemeColor("--color-danger-foreground");
const summaryResult = useAtomValue(clientCacheSummaryAtom);
const clearResult = useAtomValue(clearClientCacheAtom);
const clearCache = useAtomSet(clearClientCacheAtom);
@@ -84,7 +81,7 @@ export function SettingsClientStorageRouteScreen() {
@@ -119,7 +116,7 @@ export function SettingsClientStorageRouteScreen() {
@@ -142,14 +139,16 @@ export function SettingsClientStorageRouteScreen() {
{summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"}
- {isClearing ? : null}
+ {isClearing ? (
+
+ ) : null}
@@ -174,7 +173,6 @@ function CacheEnvironmentRow(props: {
readonly first: boolean;
readonly onClear: () => void;
}) {
- const iconColor = useThemeColor("--color-icon");
return (
diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
index 6b6d589fa4..793d265115 100644
--- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx
@@ -12,7 +12,7 @@ import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
import { splitEnvironmentSections } from "../connection/environmentSections";
import { cn } from "../../lib/cn";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useRemoteConnections } from "../../state/use-remote-environment-registry";
import {
applyShowcaseLocalEnvironmentDisplayUrls,
@@ -44,8 +44,7 @@ export function SettingsEnvironmentsRouteScreen() {
: environmentSections.connectedCloudEnvironments;
const hasLocalEnvironments = localEnvironments.length > 0;
const [expandedId, setExpandedId] = useState(null);
- const accentColor = useThemeColor("--color-icon-muted");
- const headerIconColor = useThemeColor("--color-icon");
+ const headerIconColor = useUniwindTheme()["--color-icon"];
const handleToggle = useCallback((environmentId: EnvironmentId) => {
setExpandedId((prev) => (prev === environmentId ? null : environmentId));
@@ -148,7 +147,7 @@ export function SettingsEnvironmentsRouteScreen() {
diff --git a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx
index a594240167..951168fefc 100644
--- a/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsProjectGroupingRouteScreen.tsx
@@ -8,7 +8,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { AppText as Text } from "../../components/AppText";
import { SymbolView } from "../../components/AppSymbol";
-import { useThemeColor } from "../../lib/useThemeColor";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import {
mobileProjectGroupingModePatch,
@@ -42,7 +41,6 @@ const GROUPING_OPTIONS: ReadonlyArray<{
export function SettingsProjectGroupingRouteScreen() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
- const checkmarkColor = useThemeColor("--color-icon");
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
@@ -92,7 +90,7 @@ export function SettingsProjectGroupingRouteScreen() {
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 9f2feb1c3b..9def444eea 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -33,7 +33,6 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu
import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
import { runtime } from "../../lib/runtime";
-import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
import {
@@ -595,7 +594,6 @@ function LegacySettingsSection() {
}
function AppSettingsSection() {
- const icon = useThemeColor("--color-icon");
const [updateState, setUpdateState] = useState("idle");
const updateInFlight = useRef(false);
const hiddenUpdateTapCount = useRef(0);
@@ -665,7 +663,7 @@ function AppSettingsSection() {
diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
index 96a01c0511..79d67ebaa7 100644
--- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
+++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx
@@ -1,15 +1,23 @@
-import { createContext, use, useCallback, useLayoutEffect, useMemo, type ReactNode } from "react";
-import { useColorScheme } from "react-native";
+import {
+ createContext,
+ startTransition,
+ use,
+ useCallback,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ type ReactNode,
+} from "react";
+import { Appearance, useColorScheme } from "react-native";
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
-import { Uniwind } from "uniwind";
+import { ScopedTheme, Uniwind } from "uniwind";
import {
resolveAppearance,
resolveAppearancePreferences,
- resolveTextScaleVariables,
type ResolvedAppearance,
} from "../../../lib/appearancePreferences";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
@@ -17,7 +25,6 @@ import type { Preferences } from "../../../persistence/mobile-preferences";
import {
createMobileThemePairPatch,
createMobileThemeSelectionPatch,
- getMobileThemeVariables,
normalizeMobileThemeMode,
resolveMobileThemeIds,
type MobileThemeAppearance,
@@ -25,6 +32,11 @@ import {
type MobileThemeIds,
type MobileThemeMode,
} from "../../../lib/mobileTheme";
+import {
+ createMobileThemeRuntimeOperations,
+ getMobileUniwindThemeName,
+ type MobileThemeRuntimeState,
+} from "../../../lib/mobileThemeRuntime";
import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
interface AppearancePreferencesContextValue {
@@ -51,30 +63,6 @@ interface AppearancePreferencesContextValue {
const AppearancePreferencesContext = createContext(null);
-/**
- * Injects palette and text-scale variables into both adaptive stylesheets.
- * Updating the active sheet last lets the visible app settle in one pass.
- */
-function applyAppearanceVariables(baseFontSize: number, themeIds: MobileThemeIds) {
- const textVariables = resolveTextScaleVariables(baseFontSize);
- const currentTheme = Uniwind.currentTheme;
- const activeAppearance =
- currentTheme === "light" || currentTheme === "dark" ? currentTheme : null;
-
- for (const theme of ["light", "dark"] as const) {
- const variables = { ...getMobileThemeVariables(themeIds[theme], theme), ...textVariables };
- if (theme !== activeAppearance) {
- Uniwind.updateCSSVariables(theme, variables);
- }
- }
- if (activeAppearance !== null) {
- Uniwind.updateCSSVariables(activeAppearance, {
- ...getMobileThemeVariables(themeIds[activeAppearance], activeAppearance),
- ...textVariables,
- });
- }
-}
-
export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const savePreferences = useAtomSet(updateMobilePreferencesAtom);
@@ -88,54 +76,135 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
);
const themeMode = normalizeMobileThemeMode(storedPreferences?.themeMode);
const themeAppearance = themeMode === "system" ? systemColorScheme : themeMode;
- const themeIds = useMemo(
- () => resolveMobileThemeIds(storedPreferences ?? {}),
- [storedPreferences],
+ const resolvedThemeIds = resolveMobileThemeIds(storedPreferences ?? {});
+ const themeIds = useMemo(
+ () => ({ light: resolvedThemeIds.light, dark: resolvedThemeIds.dark }),
+ [resolvedThemeIds.dark, resolvedThemeIds.light],
);
const themeId = themeIds[themeAppearance];
- const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
+ const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance);
+ const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences;
+ const appearance = useMemo(
+ () => resolveAppearance({ baseFontSize, codeFontSize, codeWordBreak, terminalFontSize }),
+ [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize],
+ );
+ // Preference patches are optimistic. Keep controls interactive while a save is
+ // in flight so rapid theme choices can supersede one another immediately.
+ const isReady = AsyncResult.isSuccess(preferencesResult);
+ const runtimeState = useMemo(
+ () => ({
+ baseFontSize,
+ themeAppearance,
+ themeMode,
+ }),
+ [baseFontSize, themeAppearance, themeMode],
+ );
+ const appliedRuntimeStateRef = useRef(null);
+ const selectedThemeIdsRef = useRef(themeIds);
- useLayoutEffect(() => {
- applyAppearanceVariables(preferences.baseFontSize, themeIds);
- Uniwind.setTheme(themeMode);
- cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);
- }, [preferences, themeIds, themeMode]);
+ const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => {
+ const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next);
+ for (const operation of operations) {
+ if (operation.kind === "update-text-variables") {
+ Uniwind.updateCSSVariables(operation.themeName, operation.variables);
+ continue;
+ }
+ if (operation.kind === "set-appearance-mode") {
+ Appearance.setColorScheme(
+ operation.themeMode === "system" ? "unspecified" : operation.appearance,
+ );
+ }
+ }
+ appliedRuntimeStateRef.current = next;
+ }, []);
+
+ const syncThemeRuntime = useCallback(
+ (next: MobileThemeRuntimeState) => applyThemeRuntime(next),
+ [applyThemeRuntime],
+ );
const updatePreferences = useCallback(
(patch: Partial) => {
+ startTransition(() => savePreferences(patch));
+ },
+ [savePreferences],
+ );
+
+ const updateThemePreferences = useCallback(
+ (patch: Partial) => {
+ // Theme selection owns the visible root ScopedTheme value. Keep its
+ // optimistic atom update urgent so the first frame after a press is the
+ // complete new palette rather than a deferred transition render.
savePreferences(patch);
},
[savePreferences],
);
+ useLayoutEffect(() => {
+ selectedThemeIdsRef.current = themeIds;
+ syncThemeRuntime(runtimeState);
+ cacheTerminalFontSize(appearance.terminalFontSize);
+ }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]);
+
const setThemeIdForAppearance = useCallback(
(appearance: MobileThemeAppearance, value: MobileThemeId) => {
- updatePreferences(
- createMobileThemeSelectionPatch(themeIds, themeAppearance, appearance, value),
+ const patch = createMobileThemeSelectionPatch(
+ selectedThemeIdsRef.current,
+ themeAppearance,
+ appearance,
+ value,
);
+ selectedThemeIdsRef.current = resolveMobileThemeIds(patch);
+ updateThemePreferences(patch);
},
- [themeAppearance, themeIds, updatePreferences],
+ [themeAppearance, updateThemePreferences],
);
const setThemeIdForBothAppearances = useCallback(
(value: MobileThemeId) => {
- updatePreferences(createMobileThemePairPatch(value));
+ const patch = createMobileThemePairPatch(value);
+ selectedThemeIdsRef.current = resolveMobileThemeIds(patch);
+ updateThemePreferences(patch);
},
- [updatePreferences],
+ [updateThemePreferences],
);
const setThemeMode = useCallback(
(value: MobileThemeMode) => {
- updatePreferences({ themeMode: value });
+ const current = appliedRuntimeStateRef.current ?? runtimeState;
+
+ // Clear a forced native appearance before publishing System. The
+ // resulting useColorScheme notification still sees the previous forced
+ // preference, so React batches the actual system palette into the one
+ // urgent preference commit below.
+ if (value === "system") {
+ Appearance.setColorScheme("unspecified");
+ }
+ const nextAppearance =
+ value === "system" ? (Appearance.getColorScheme() === "dark" ? "dark" : "light") : value;
+ const next = {
+ ...current,
+ themeAppearance: nextAppearance,
+ themeMode: value,
+ };
+
+ updateThemePreferences({ themeMode: value });
+ if (value === "system") {
+ appliedRuntimeStateRef.current = next;
+ } else {
+ syncThemeRuntime(next);
+ }
},
- [updatePreferences],
+ [runtimeState, syncThemeRuntime, updateThemePreferences],
);
const setBaseFontSize = useCallback(
(value: number) => {
+ const current = appliedRuntimeStateRef.current ?? runtimeState;
+ syncThemeRuntime({ ...current, baseFontSize: value });
updatePreferences({ baseFontSize: value });
},
- [updatePreferences],
+ [runtimeState, syncThemeRuntime, updatePreferences],
);
const setTerminalFontSize = useCallback(
@@ -161,7 +230,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
const value = useMemo(
(): AppearancePreferencesContextValue => ({
- appearance: resolveAppearance(preferences),
+ appearance,
themeId,
themeIds,
themeMode,
@@ -176,7 +245,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
setCodeWordBreak,
}),
[
- preferences,
+ appearance,
themeId,
themeIds,
themeMode,
@@ -194,7 +263,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
return (
- {props.children}
+ {props.children}
);
}
diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
index f9275eb373..55bd661a64 100644
--- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
+++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx
@@ -5,7 +5,7 @@ import {
resolveMarkdownFontSizes,
resolveMobileCodeSurface,
} from "../../../../lib/appearancePreferences";
-import { useThemeColor } from "../../../../lib/useThemeColor";
+import { useUniwindTheme } from "../../../../lib/useUniwindTheme";
import { getMobileTerminalTheme } from "../../../terminal/terminalTheme";
import { useAppearancePreferences } from "../AppearancePreferencesProvider";
@@ -138,8 +138,9 @@ export function CodeAppearancePreview(props: {
readonly wordBreak: boolean;
}) {
const surface = resolveMobileCodeSurface(props.fontSize);
- const lineNumberColor = useThemeColor("--color-icon-subtle");
- const keywordColor = useThemeColor("--color-md-link");
+ const theme = useUniwindTheme();
+ const lineNumberColor = theme["--color-icon-subtle"];
+ const keywordColor = theme["--color-md-link"];
const lineNumber = (line: CodePreviewLine, index: number) => (
["name"];
@@ -36,10 +36,9 @@ export function FontSizeSliderRow(props: {
readonly value: number;
readonly onChange: (value: number) => void;
}) {
- const icon = useThemeColor("--color-icon");
- const iconMuted = String(useThemeColor("--color-icon-muted"));
- const trackColor = String(useThemeColor("--color-secondary-border"));
- const fillColor = String(useThemeColor("--color-primary"));
+ const theme = useUniwindTheme();
+ const trackColor = theme["--color-secondary-border"];
+ const fillColor = theme["--color-primary"];
const latest = useRef(props);
latest.current = props;
@@ -141,7 +140,7 @@ export function FontSizeSliderRow(props: {
@@ -152,7 +151,7 @@ export function FontSizeSliderRow(props: {
@@ -204,7 +203,7 @@ export function FontSizeSliderRow(props: {
diff --git a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx
index ab2a993139..2257115828 100644
--- a/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx
+++ b/apps/mobile/src/features/settings/appearance/sections/ThemeAppearanceSection.tsx
@@ -1,22 +1,22 @@
import { memo, useId } from "react";
import { Pressable, View } from "react-native";
import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg";
+import { ScopedTheme } from "uniwind";
import { mixThemePreviewBase, THEME_PREVIEW_RENDER_SPECS } from "@t3tools/shared/themePreview";
import { SymbolView } from "../../../../components/AppSymbol";
import { AppText as Text } from "../../../../components/AppText";
import {
- getMobileThemeVariables,
getMobileThemePreviewColors,
MOBILE_THEME_OPTIONS,
type MobileThemeAppearance,
type MobileThemeId,
type MobileThemeIds,
type MobileThemeMode,
- type MobileThemeVariables,
} from "../../../../lib/mobileTheme";
-import { useThemeColor } from "../../../../lib/useThemeColor";
+import { getMobileUniwindThemeName } from "../../../../lib/mobileThemeRuntime";
+import { cn } from "../../../../lib/cn";
import { useAppearancePreferences } from "../AppearancePreferencesProvider";
const APPEARANCE_MODES: ReadonlyArray<{
@@ -28,6 +28,8 @@ const APPEARANCE_MODES: ReadonlyArray<{
{ id: "dark", label: "Dark" },
];
+const previewPercentage = (value: number) => `${value * 100}%`;
+
const PreviewOrb = memo(function PreviewOrb(props: {
readonly appearance: MobileThemeAppearance;
readonly compact?: boolean;
@@ -46,9 +48,6 @@ const PreviewOrb = memo(function PreviewOrb(props: {
Math.max(spec.action.center[0], 1 - spec.action.center[0]),
Math.max(spec.action.center[1], 1 - spec.action.center[1]),
);
- const position = (value: number) => `${value * 100}%`;
- const radius = (value: number) => `${value * 100}%`;
-
return (
@@ -118,32 +117,26 @@ function ThemeCard(props: {
readonly onSelect: (appearance: MobileThemeAppearance) => void;
readonly themeId: MobileThemeId;
}) {
- const badgeBackground = useThemeColor("--color-card");
- const badgeIcon = useThemeColor("--color-icon");
-
const choice = (appearance: MobileThemeAppearance, selected: boolean) => (
props.onSelect(appearance)}
>
{selected ? (
-
+
@@ -158,7 +151,10 @@ function ThemeCard(props: {
accessibilityHint="Sets both light and dark appearances"
accessibilityLabel={`${props.label} theme`}
accessibilityRole="button"
- accessibilityState={{ disabled: props.disabled }}
+ accessibilityState={{
+ disabled: props.disabled,
+ selected: props.lightSelected && props.darkSelected,
+ }}
className="absolute inset-0 rounded-[24px] active:bg-subtle"
disabled={props.disabled}
onPress={props.onSelectBoth}
@@ -167,34 +163,26 @@ function ThemeCard(props: {
{choice("light", props.lightSelected)}
{choice("dark", props.darkSelected)}
-
-
- {props.label}
-
-
+
+ {props.label}
+
);
}
-function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly compact?: boolean }) {
+function PreviewPane(props: { readonly compact?: boolean }) {
return (
-
+
-
-
+
+
-
-
+
+
-
-
+
+
@@ -228,50 +204,31 @@ function PreviewPane(props: { readonly colors: MobileThemeVariables; readonly co
}
function ModePreview(props: { readonly mode: MobileThemeMode; readonly themeIds: MobileThemeIds }) {
- const light = getMobileThemeVariables(props.themeIds.light, "light");
- const dark = getMobileThemeVariables(props.themeIds.dark, "dark");
- const currentBorder = useThemeColor("--color-border");
- const currentFrame = useThemeColor("--color-drawer");
- const currentIndicator = useThemeColor("--color-foreground-muted");
- const frameColor =
- props.mode === "light"
- ? light["--color-border"]
- : props.mode === "dark"
- ? dark["--color-border"]
- : currentBorder;
- const frameBackground =
- props.mode === "light"
- ? light["--color-drawer"]
- : props.mode === "dark"
- ? dark["--color-drawer"]
- : currentFrame;
- const indicatorColor =
- props.mode === "light"
- ? light["--color-foreground-muted"]
- : props.mode === "dark"
- ? dark["--color-foreground-muted"]
- : currentIndicator;
+ if (props.mode === "system") {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
return (
-
-
- {props.mode === "system" ? (
- <>
-
-
- >
- ) : (
-
- )}
+
+
+
+
+
+
-
-
+
);
}
@@ -288,11 +245,10 @@ function ModeCard(props: {
accessibilityLabel={`${props.label} appearance`}
accessibilityRole="radio"
accessibilityState={{ checked: props.selected, disabled: props.disabled }}
- className={
- props.selected
- ? "min-w-0 flex-1 gap-2 rounded-[24px] border-2 border-primary bg-subtle p-2"
- : "min-w-0 flex-1 gap-2 rounded-[24px] border border-border bg-card p-2"
- }
+ className={cn(
+ "min-w-0 flex-1 gap-2 rounded-[24px] p-2 active:scale-[0.97]",
+ props.selected ? "border-2 border-primary bg-subtle" : "border border-border bg-card",
+ )}
disabled={props.disabled}
onPress={props.onPress}
>
diff --git a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts
index 4224740c26..62f918a0e6 100644
--- a/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts
+++ b/apps/mobile/src/features/settings/appearance/useScaledTextRole.ts
@@ -1,18 +1,12 @@
-import { useCSSVariable } from "uniwind";
+import { useMemo } from "react";
+import {
+ DEFAULT_BASE_FONT_SIZE,
+ normalizeBaseFontSize,
+ scaledTypographyLineHeight,
+} from "../../../lib/appearancePreferences";
import { MOBILE_TYPOGRAPHY } from "../../../lib/typography";
-
-const TEXT_ROLE_VARIABLES = {
- micro: "--text-3xs",
- caption: "--text-2xs",
- label: "--text-xs",
- footnote: "--text-sm",
- body: "--text-base",
- headline: "--text-lg",
- title: "--text-xl",
- largeTitle: "--text-2xl",
- display: "--text-3xl",
-} as const satisfies Record;
+import { useAppearancePreferences } from "./AppearancePreferencesProvider";
export interface ScaledTextRole {
readonly fontSize: number;
@@ -20,17 +14,21 @@ export interface ScaledTextRole {
}
/**
- * Reads a typography role's current size from the Uniwind `--text-*` CSS
- * variables (scaled at runtime with the base font size). Use for style-prop
- * consumers that can't express their size as a `text-*` className. Reactive:
- * re-renders when the appearance provider re-injects the variables.
+ * Mirrors the values injected into Uniwind for style-prop consumers that
+ * cannot use a `text-*` class. This deliberately does not subscribe to CSS
+ * variables, so palette-only setTheme calls remain native-only.
*/
export function useScaledTextRole(role: keyof typeof MOBILE_TYPOGRAPHY): ScaledTextRole {
- const variable = TEXT_ROLE_VARIABLES[role];
- const [fontSize, lineHeight] = useCSSVariable([variable, `${variable}--line-height`]);
-
- return {
- fontSize: typeof fontSize === "number" ? fontSize : MOBILE_TYPOGRAPHY[role].fontSize,
- lineHeight: typeof lineHeight === "number" ? lineHeight : MOBILE_TYPOGRAPHY[role].lineHeight,
- };
+ const { appearance } = useAppearancePreferences();
+ return useMemo(() => {
+ const baseFontSize = normalizeBaseFontSize(appearance.baseFontSize);
+ const typography = MOBILE_TYPOGRAPHY[role];
+ return {
+ fontSize: Math.max(
+ 8,
+ Math.round(typography.fontSize * (baseFontSize / DEFAULT_BASE_FONT_SIZE)),
+ ),
+ lineHeight: scaledTypographyLineHeight(typography, baseFontSize),
+ };
+ }, [appearance.baseFontSize, role]);
}
diff --git a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx
index aa5303b9a8..86e1008a14 100644
--- a/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx
@@ -6,12 +6,10 @@ import { WebView } from "react-native-webview";
import { AppText as Text } from "../../../components/AppText";
import { LoadingStrip } from "../../../components/LoadingStrip";
import { SymbolView } from "../../../components/AppSymbol";
-import { useThemeColor } from "../../../lib/useThemeColor";
import { isLegalDocumentUrl, LEGAL_URL } from "../lib/legal-document-url";
export function SettingsLegalDocumentCloseHeaderButton() {
const navigation = useNavigation();
- const iconColor = useThemeColor("--color-icon");
return (
@@ -37,7 +35,6 @@ export function SettingsLegalDocumentExternalHeaderButton({
}: {
readonly externalUrl?: string;
}) {
- const iconColor = useThemeColor("--color-icon");
const safeExternalUrl = isLegalDocumentUrl(externalUrl) ? externalUrl : LEGAL_URL;
return (
@@ -51,7 +48,7 @@ export function SettingsLegalDocumentExternalHeaderButton({
@@ -69,7 +66,6 @@ export function SettingsLegalDocumentRouteScreen({
documentUrl,
}: SettingsLegalDocumentRouteScreenProps) {
const navigation = useNavigation>();
- const iconColor = useThemeColor("--color-icon");
const [reloadKey, setReloadKey] = useState(0);
const [loadProgress, setLoadProgress] = useState(0);
const [loadError, setLoadError] = useState(null);
@@ -94,7 +90,7 @@ export function SettingsLegalDocumentRouteScreen({
diff --git a/apps/mobile/src/features/settings/components/SettingsRow.tsx b/apps/mobile/src/features/settings/components/SettingsRow.tsx
index fcdcf7982f..f15f21b9ac 100644
--- a/apps/mobile/src/features/settings/components/SettingsRow.tsx
+++ b/apps/mobile/src/features/settings/components/SettingsRow.tsx
@@ -5,7 +5,6 @@ import { Pressable, View } from "react-native";
import { SymbolView } from "../../../components/AppSymbol";
import { AppText as Text } from "../../../components/AppText";
-import { useThemeColor } from "../../../lib/useThemeColor";
import type { SettingsLegalDocumentTarget, SettingsSheetTarget } from "./settings-sheet-targets";
type SymbolName = ComponentProps["name"];
@@ -20,8 +19,6 @@ export function SettingsRow(props: {
readonly onPress?: () => void;
}) {
const navigation = useNavigation();
- const icon = useThemeColor("--color-icon");
- const chevron = useThemeColor("--color-chevron");
const content = (
-
+
{props.label}
@@ -48,7 +51,7 @@ export function SettingsRow(props: {
diff --git a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx
index 2a63385a04..3abda36af6 100644
--- a/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx
+++ b/apps/mobile/src/features/settings/components/SettingsSwitchRow.tsx
@@ -4,7 +4,6 @@ import { View } from "react-native";
import { SymbolView } from "../../../components/AppSymbol";
import { AppText as Text } from "../../../components/AppText";
import { ThemedSwitch } from "../../../components/ThemedSwitch";
-import { useThemeColor } from "../../../lib/useThemeColor";
type SymbolName = ComponentProps["name"];
@@ -16,8 +15,6 @@ export function SettingsSwitchRow(props: {
readonly value: boolean;
readonly onValueChange: (value: boolean) => void;
}) {
- const icon = useThemeColor("--color-icon");
-
return (
-
+
{props.label}
{props.subtitle ? (
diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
index b6466abc27..e3fa761d65 100644
--- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
+++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx
@@ -4,7 +4,6 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { Pressable, View } from "react-native";
import { AppText as Text } from "../../components/AppText";
-import { useThemeColor } from "../../lib/useThemeColor";
import { terminalEnvironment } from "../../state/terminal";
import { useAtomCommand } from "../../state/use-atom-command";
import { useAttachedTerminalSession } from "../../state/use-terminal-session";
@@ -36,7 +35,6 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
const closeTerminal = useAtomCommand(terminalEnvironment.close, "terminal close");
const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open");
const nativeTerminalAvailable = hasNativeTerminalSurface();
- const iconColor = useThemeColor("--color-icon");
const terminalId = DEFAULT_TERMINAL_ID;
const lastGridSizeRef = useRef({
cols: DEFAULT_TERMINAL_COLS,
@@ -236,7 +234,12 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel(
className="h-8 w-8 items-center justify-center rounded-[8px] bg-subtle"
onPress={props.onClose}
>
-
+
diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
index cbc47c99c2..7ecb9f6413 100644
--- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
+++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx
@@ -5,13 +5,12 @@ import {
import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
import { memo } from "react";
-import { Pressable, ScrollView, View, type ViewStyle } from "react-native";
+import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";
import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { GlassSurface } from "../../components/GlassSurface";
import { PierreEntryIcon } from "../../components/PierreEntryIcon";
-import { useThemeColor } from "../../lib/useThemeColor";
export type ComposerCommandItem =
| {
readonly id: string;
@@ -51,7 +50,6 @@ interface ComposerCommandPopoverProps {
}
function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) {
- const tintColor = useThemeColor("--color-glass-surface");
const baseStyle: ViewStyle = {
borderRadius: 16,
overflow: "hidden",
@@ -59,7 +57,11 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st
};
return (
-
+
{props.children}
);
@@ -122,27 +124,23 @@ const CommandRow = memo(function CommandRow(props: {
readonly isSlashSkill: boolean;
}) {
const iconName = itemIcon(props.item);
- const iconColor = useThemeColor("--color-icon-subtle");
- const borderColor = useThemeColor("--color-border");
return (
({
- flexDirection: "row",
- alignItems: "center",
- paddingHorizontal: 14,
- paddingVertical: 10,
- gap: 10,
- opacity: pressed ? 0.6 : 1,
- borderBottomWidth: props.isLast ? 0 : 0.5,
- borderBottomColor: borderColor,
- })}
+ className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60"
+ style={{ borderBottomWidth: props.isLast ? 0 : StyleSheet.hairlineWidth }}
>
{props.item.type === "path" ? (
) : iconName ? (
-
+
) : null}
{props.isSlashSkill && props.item.type === "skill" ? (
diff --git a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx
index 8aeadc95cb..086f41a1bc 100644
--- a/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx
+++ b/apps/mobile/src/features/threads/GitActionProgressOverlay.tsx
@@ -2,16 +2,16 @@ import * as Haptics from "expo-haptics";
import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useEffect, useRef } from "react";
-import { ActivityIndicator, Pressable, StyleSheet, View } from "react-native";
+import { ActivityIndicator, Pressable, StyleSheet, useColorScheme, View } from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
import { APP_BAR_HEIGHT } from "../../lib/layoutMetrics";
+import { themeColorWithAlpha } from "../../lib/mobileTheme";
import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { GitActionProgress } from "../../state/use-vcs-action-state";
-import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
const OVERLAY_LAYOUT_TRANSITION = LinearTransition.duration(220);
const OVERLAY_TOP_GAP = 8;
@@ -67,14 +67,16 @@ export function GitActionProgressOverlay(props: {
function OverlayContent(props: { readonly progress: GitActionProgress }) {
const { progress } = props;
- const iconColor = useThemeColor("--color-icon");
- const glassBorder = useThemeColor("--color-header-border");
- const glassTint = useThemeColor("--color-glass-tint");
- const { themeAppearance } = useAppearancePreferences();
- const isDarkMode = themeAppearance === "dark";
+ const colors = useUniwindTheme();
+ const glassSurface = colors["--color-glass-surface"];
+ const foreground = colors["--color-foreground"];
+ const shadowColor = colors["--color-primary-shadow"];
+ const isDarkMode = useColorScheme() === "dark";
+ const glassTint = themeColorWithAlpha(String(glassSurface), isDarkMode ? 0.48 : 0.38);
+ const glassBorder = themeColorWithAlpha(String(foreground), isDarkMode ? 0.18 : 0.12);
const content = (
<>
-
+
{progress.label ? (
@@ -90,7 +92,12 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {
{progress.prUrl ? (
-
+
) : null}
>
);
@@ -100,12 +107,13 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {
@@ -132,7 +143,7 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {
const bgClass =
progress.phase === "error"
- ? "bg-red-50 dark:bg-red-950/80 border-red-200 dark:border-red-800"
+ ? "border-adaptive-red-200-800 bg-adaptive-red-50-950-a80"
: "bg-card border-border";
return (
@@ -145,13 +156,10 @@ function OverlayContent(props: { readonly progress: GitActionProgress }) {
);
}
-function OverlayIcon(props: {
- readonly phase: GitActionProgress["phase"];
- readonly iconColor: ReturnType;
-}) {
+function OverlayIcon(props: { readonly phase: GitActionProgress["phase"] }) {
switch (props.phase) {
case "running":
- return ;
+ return ;
case "success":
return (
diff --git a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
index 97bb2ab982..411598db08 100644
--- a/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
+++ b/apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx
@@ -24,7 +24,7 @@ import { AppText as Text } from "../../components/AppText";
import { ThemedSwitch } from "../../components/ThemedSwitch";
import { cn } from "../../lib/cn";
import { useFontFamily } from "../../lib/useFontFamily";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader";
import { useAtomCommand } from "../../state/use-atom-command";
import { vcsEnvironment } from "../../state/vcs";
@@ -45,9 +45,6 @@ function SelectionRow(props: {
readonly subtitle?: string;
readonly title: string;
}) {
- const iconColor = useThemeColor("--color-icon-muted");
- const checkmarkColor = useThemeColor("--color-icon");
-
return (
{props.icon ? (
-
+
) : null}
@@ -78,7 +80,7 @@ function SelectionRow(props: {
@@ -191,8 +193,7 @@ export function NewTaskBranchPickerRouteScreen() {
const flow = useNewTaskFlow();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
- const placeholderColor = useThemeColor("--color-placeholder");
- const foregroundColor = useThemeColor("--color-foreground");
+ const foregroundColor = useUniwindTheme()["--color-foreground"];
const fontFamily = useFontFamily("regular");
const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
const [switchingBranchName, setSwitchingBranchName] = useState(null);
@@ -419,7 +420,7 @@ export function NewTaskBranchPickerRouteScreen() {
className="h-11 rounded-xl bg-card px-4 text-base text-foreground"
onChangeText={flow.setBranchQuery}
placeholder="Find a branch"
- placeholderTextColor={placeholderColor}
+ placeholderTextColorClassName={"accent-placeholder"}
style={{ color: foregroundColor, fontFamily }}
value={flow.branchQuery}
/>
diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
index 8f5beb69c9..5dbb1ed835 100644
--- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
+++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
@@ -13,8 +13,7 @@ import {
useKeyboardState,
} from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useThemeColor } from "../../lib/useThemeColor";
-import { themeColorWithAlpha } from "../../lib/mobileTheme";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import { useFontFamily } from "../../lib/useFontFamily";
import {
@@ -35,6 +34,8 @@ import { ProviderIcon } from "../../components/ProviderIcon";
import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text } from "../../components/AppText";
import { ComposerSurface } from "./ThreadComposer";
+import { ComposerCommandPopover } from "./ComposerCommandPopover";
+import { useComposerCommandMenu } from "./use-composer-command-menu";
import {
useThreadSettingsSheetPresentation,
type NavigationWithFinishTransitioning,
@@ -43,7 +44,6 @@ import {
import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { convertPastedImagesToAttachments, pickComposerImages } from "../../lib/composerImages";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
-import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import {
clearComposerDraftContent,
getComposerDraftSnapshot,
@@ -71,17 +71,32 @@ function NewTaskWorkspaceIcon(props: {
readonly workspaceMode: "local" | "worktree";
readonly worktreePath: string | null;
}) {
- const iconColor = useThemeColor("--color-icon-muted");
-
if (props.workspaceMode === "local" && props.worktreePath === null) {
- return ;
+ return (
+
+ );
}
return (
-
+
-
+
);
@@ -109,7 +124,6 @@ export function NewTaskDraftScreen(props: {
reserveShare,
} = useIncomingShare();
const insets = useSafeAreaInsets();
- const { themeAppearance: colorScheme } = useAppearancePreferences();
const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
const controlsBottomPadding = Math.max(insets.bottom, 10);
const keyboardOpenedOffset = Math.max(0, controlsBottomPadding - 8);
@@ -204,6 +218,19 @@ export function NewTaskDraftScreen(props: {
const isIncomingShareTransferPending = Boolean(
incomingShare && cancelledIncomingShareId !== props.incomingShareId,
);
+ const composerMenu = useComposerCommandMenu({
+ draftMessage: flow.prompt,
+ environmentId: selectedProject?.environmentId ?? null,
+ projectCwd:
+ (flow.workspaceMode === "worktree"
+ ? selectedProject?.workspaceRoot
+ : (flow.selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null,
+ selectedProviderStatus: flow.selectedProviderStatus,
+ hasThread: false,
+ enabled: isComposerFocused && !isIncomingShareTransferPending,
+ onChangeDraftMessage: flow.setPrompt,
+ onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined,
+ });
usePreventRemove(
(isIncomingShareTransferPending && !isProjectPickerReturnActive) || isCancellingShareImport,
() => undefined,
@@ -291,13 +318,9 @@ export function NewTaskDraftScreen(props: {
};
}, [props.pendingTaskId, cancelEditingPendingTask]);
- const foregroundColor = useThemeColor("--color-foreground");
- const sheetColor = String(useThemeColor("--color-sheet"));
- const projectUnderlineColor = useThemeColor("--color-foreground-muted");
+ const foregroundColor = useUniwindTheme()["--color-foreground"];
const regularFontFamily = useFontFamily("regular");
const bodyText = useScaledTextRole("body");
- const sheetFadeOpaque = sheetColor;
- const sheetFadeTransparent = themeColorWithAlpha(sheetColor, 0);
// A new navigation to this mounted screen delivers a fresh initialProjectRef
// reference — treat it as a new request and let it apply again.
@@ -704,10 +727,12 @@ export function NewTaskDraftScreen(props: {
if (editingPendingTask) {
flow.finishEditingPendingTask();
} else {
- // Drop the workspace selection with the content: the next task should
- // re-resolve mode/branch/origin from the server's configured defaults
- // instead of resurrecting this task's picks.
- clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
+ // Drop draft-local model/workspace selections with the content. The
+ // next task re-resolves project defaults before sticky app defaults.
+ clearComposerDraftContent(draftKey, {
+ clearModelSelection: true,
+ clearWorkspaceSelection: true,
+ });
}
navigation.getParent()?.goBack();
return;
@@ -771,7 +796,10 @@ export function NewTaskDraftScreen(props: {
}
flow.finishEditingPendingTask();
} else {
- clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true });
+ clearComposerDraftContent(draftKey, {
+ clearModelSelection: true,
+ clearWorkspaceSelection: true,
+ });
}
navigation.dispatch(
StackActions.replace("Thread", {
@@ -797,7 +825,6 @@ export function NewTaskDraftScreen(props: {
}
const isAndroid = Platform.OS === "android";
- const isDarkMode = colorScheme === "dark";
const canStart =
Boolean(flow.selectedProject) &&
Boolean(flow.selectedModel) &&
@@ -816,8 +843,10 @@ export function NewTaskDraftScreen(props: {
multiline
scrollEnabled
value={flow.prompt}
- skills={flow.selectedProviderSkills}
+ skills={flow.selectedProviderStatus?.skills ?? []}
+ selection={composerMenu.selection}
onChangeText={flow.setPrompt}
+ onSelectionChange={composerMenu.onSelectionChange}
onFocus={() => setIsComposerFocused(true)}
onBlur={() => setIsComposerFocused(false)}
onPasteImages={(uris) => void handleNativePasteImages(uris)}
@@ -874,11 +903,7 @@ export function NewTaskDraftScreen(props: {
accessibilityRole="button"
disabled={isIncomingShareTransferPending}
onPress={chooseProject}
- className="min-w-0 max-w-[250px] active:opacity-65"
- style={{
- borderBottomColor: projectUnderlineColor,
- borderBottomWidth: 1,
- }}
+ className="min-w-0 max-w-[250px] border-b border-foreground-muted active:opacity-65"
>
+ {composerMenu.trigger && composerMenu.items.length > 0 ? (
+
+
+
+ ) : null}
{workspaceControls}
-
+
{projectScopes.length === 0 ? (
- {projectEmptyState.loading ? : null}
+ {projectEmptyState.loading ? (
+
+ ) : null}
{projectEmptyState.title}
@@ -308,7 +307,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps
diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx
index fb9cc72d25..0239cac1e0 100644
--- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx
+++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx
@@ -28,15 +28,15 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
// Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
// behind this card, so a translucent surface bleeds messages through it.
return (
-
-
+
+
Approval needed
-
+
{props.approval.appName ?? props.approval.requestKind}
{props.approval.detail ? (
-
+
{props.approval.detail}
) : null}
@@ -48,8 +48,8 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
option.decision === "accept"
? "bg-blue-500"
: option.decision === "decline"
- ? "bg-rose-100 dark:bg-rose-500/18"
- : "bg-neutral-200 dark:bg-neutral-800"
+ ? "bg-adaptive-rose-100-500-a18"
+ : "bg-adaptive-neutral-200-800"
}`}
disabled={props.respondingApprovalId === props.approval.requestId}
onPress={() => void props.onRespond(props.approval.requestId, option.decision)}
@@ -59,8 +59,8 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
option.decision === "accept"
? "font-t3-extrabold text-white"
: option.decision === "decline"
- ? "font-t3-bold text-rose-700 dark:text-rose-300"
- : "font-t3-bold text-neutral-950 dark:text-neutral-50"
+ ? "font-t3-bold text-adaptive-rose-700-300"
+ : "font-t3-bold text-adaptive-neutral-950-50"
}`}
>
{option.label}
diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx
index 4b5a93cd1f..5700d1b79e 100644
--- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx
+++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx
@@ -18,7 +18,6 @@ import { SymbolView } from "../../components/AppSymbol";
import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
import { ControlPill } from "../../components/ControlPill";
import { cn } from "../../lib/cn";
-import { useThemeColor } from "../../lib/useThemeColor";
import {
isPendingUserInputOptionSelected,
type PendingUserInput,
@@ -87,7 +86,6 @@ const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";
const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200);
export function PendingUserInputCard(props: PendingUserInputCardProps) {
- const iconSubtle = useThemeColor("--color-icon-subtle");
const questionCount = props.pendingUserInput.questions.length;
const cardCoverage = props.cardCoverage;
@@ -163,7 +161,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
pointerEvents={props.collapsed ? "auto" : "none"}
accessibilityElementsHidden={!props.collapsed}
importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"}
- className="flex-row items-center gap-2 rounded-full border border-neutral-200 bg-neutral-100 py-1.5 pl-4 pr-1.5 dark:border-white/6 dark:bg-neutral-900"
+ className="flex-row items-center gap-2 rounded-full border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 py-1.5 pl-4 pr-1.5"
>
-
+
User input needed
-
+
{questionCount} question{questionCount === 1 ? "" : "s"}
-
+
{props.onStopThread ? (
-
+
User input needed
-
+
Fill in the pending answers
-
-
+
+
-
+
{question.header}
-
+
{question.question}
@@ -268,8 +276,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
className={cn(
"min-h-12 w-full rounded-2xl border px-3.5 py-3",
selected
- ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"
- : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70",
+ ? "border-adaptive-blue-300-a50-blue-400-a28 bg-adaptive-blue-50-blue-400-a14"
+ : "border-adaptive-neutral-200-white-a6 bg-adaptive-white-neutral-950-a70",
)}
onPress={() =>
props.onSelectOption(
@@ -284,14 +292,14 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
className={cn(
"font-t3-bold text-sm",
selected
- ? "text-sky-700 dark:text-sky-300"
- : "text-neutral-700 dark:text-neutral-200",
+ ? "text-adaptive-sky-700-300"
+ : "text-adaptive-neutral-600-300",
)}
>
{option.label}
{description ? (
-
+
{description}
) : null}
@@ -308,7 +316,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
onFocus={() => props.onInputFocusChange?.(true)}
onBlur={() => props.onInputFocusChange?.(false)}
placeholder="Or type a custom answer"
- className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50"
+ className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50"
/>
);
@@ -317,7 +325,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
{isReconnecting ? (
-
+
) : (
)}
@@ -272,9 +232,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill(
export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposerProps) {
const navigation = useNavigation();
- const { themeAppearance } = useAppearancePreferences();
- const isDarkMode = themeAppearance === "dark";
- const foregroundColor = useThemeColor("--color-foreground");
+ const foregroundColor = useUniwindTheme()["--color-foreground"];
const bodyText = useScaledTextRole("body");
const fallbackInputRef = useRef(null);
const inputRef = props.editorRef ?? fallbackInputRef;
@@ -342,11 +300,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
environmentLabel: props.environmentLabel,
threadSyncPhase: props.threadSyncPhase,
});
- const toolbarSurface = String(useThemeColor("--color-card"));
- const backdropSurface = String(useThemeColor("--color-screen"));
- const toolbarFadeOpaque = themeColorWithAlpha(toolbarSurface, 0.95);
- const toolbarFadeTransparent = themeColorWithAlpha(toolbarSurface, 0);
- const backdropGradient = `linear-gradient(to bottom, ${themeColorWithAlpha(backdropSurface, 0)} 0%, ${themeColorWithAlpha(backdropSurface, 0.6)} 55%, ${themeColorWithAlpha(backdropSurface, 0.9)} 100%)`;
const selectedProviderStatus = useMemo(() => {
if (!props.serverConfig) return null;
return (
@@ -356,212 +309,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
);
}, [props.serverConfig, props.selectedThread.modelSelection.instanceId]);
- // ── Trigger detection ────────────────────────────────────
- const [composerSelection, setComposerSelection] = useState(() => ({
- start: props.draftMessage.length,
- end: props.draftMessage.length,
- }));
-
- 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(() => {
- if (composerSelection.start !== composerSelection.end) {
- return null;
- }
- return detectComposerTrigger(props.draftMessage, composerSelection.end);
- }, [composerSelection, props.draftMessage]);
- const pathSearch = useComposerPathSearch({
+ const composerMenu = useComposerCommandMenu({
+ draftMessage: props.draftMessage,
environmentId: props.environmentId,
- cwd: composerTrigger?.kind === "path" ? props.projectCwd : null,
- query: composerTrigger?.kind === "path" ? composerTrigger.query : null,
+ projectCwd: props.projectCwd,
+ selectedProviderStatus,
+ hasThread: true,
+ onChangeDraftMessage: props.onChangeDraftMessage,
+ onUpdateInteractionMode: props.onUpdateInteractionMode,
});
-
- const composerMenuItems: ComposerCommandItem[] = useMemo(() => {
- if (!composerTrigger) return [];
-
- if (composerTrigger.kind === "slash-command") {
- const q = composerTrigger.query.toLowerCase();
- const hasProviderGoalCommand = (selectedProviderStatus?.slashCommands ?? []).some(
- (command) => command.name.toLowerCase() === "goal",
- );
- const goalCommandItems =
- selectedProviderStatus?.driver === "codex" && !hasProviderGoalCommand
- ? [
- {
- id: "cmd:goal",
- type: "slash-command" as const,
- command: "goal",
- label: "/goal",
- description: "Set, view, pause, resume, or clear a task goal",
- },
- ]
- : [];
- const allBuiltIn = [
- {
- id: "cmd:model",
- type: "slash-command" as const,
- command: "model",
- label: "/model",
- description: "Switch model",
- },
- {
- id: "cmd:plan",
- type: "slash-command" as const,
- command: "plan",
- label: "/plan",
- description: "Switch to plan mode",
- },
- {
- id: "cmd:default",
- type: "slash-command" as const,
- command: "default",
- label: "/default",
- description: "Switch to default mode",
- },
- ...goalCommandItems,
- ];
- const builtIn = allBuiltIn.filter((item) => item.command.includes(q));
-
- const providerCommands: ComposerCommandItem[] = [];
- for (const cmd of selectedProviderStatus?.slashCommands ?? []) {
- if (!cmd.name.toLowerCase().includes(q)) continue;
- providerCommands.push({
- id: `pcmd:${cmd.name}`,
- type: "provider-slash-command" as const,
- command: cmd,
- label: `/${cmd.name}`,
- description: cmd.description ?? "",
- });
- }
-
- const skillItems = (selectedProviderStatus?.skills ?? [])
- .filter((skill) => matchesSlashSkillQuery(skill, q))
- .map((skill) => ({
- id: `skill:${skill.name}`,
- type: "skill" as const,
- skill,
- label: `skill:${skill.name}`,
- description: skill.shortDescription ?? skill.description ?? "",
- }));
-
- return [...builtIn, ...providerCommands, ...skillItems];
- }
-
- if (composerTrigger.kind === "skill") {
- const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
- const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {
- trimLeadingPattern: /^\$+/,
- });
-
- if (!normalizedQuery) {
- return enabledSkills.slice(0, 20).map((skill) => ({
- id: `skill:${skill.name}`,
- type: "skill" as const,
- skill,
- label: skill.displayName ?? skill.name,
- description: skill.shortDescription ?? skill.description ?? "",
- }));
- }
-
- const ranked: Array<{
- item: (typeof enabledSkills)[number];
- score: number;
- tieBreaker: string;
- }> = [];
- for (const skill of enabledSkills) {
- const displayLabel = (skill.displayName ?? skill.name).toLowerCase();
- const scores = [
- scoreQueryMatch({
- value: skill.name.toLowerCase(),
- query: normalizedQuery,
- exactBase: 0,
- prefixBase: 2,
- boundaryBase: 4,
- includesBase: 6,
- fuzzyBase: 100,
- boundaryMarkers: ["-", "_", "/"],
- }),
- scoreQueryMatch({
- value: displayLabel,
- query: normalizedQuery,
- exactBase: 1,
- prefixBase: 3,
- boundaryBase: 5,
- includesBase: 7,
- fuzzyBase: 110,
- }),
- scoreQueryMatch({
- value: skill.shortDescription?.toLowerCase() ?? "",
- query: normalizedQuery,
- exactBase: 20,
- prefixBase: 22,
- boundaryBase: 24,
- includesBase: 26,
- }),
- scoreQueryMatch({
- value: skill.description?.toLowerCase() ?? "",
- query: normalizedQuery,
- exactBase: 30,
- prefixBase: 32,
- boundaryBase: 34,
- includesBase: 36,
- }),
- ].filter((s): s is number => s !== null);
-
- if (scores.length > 0) {
- insertRankedSearchResult(
- ranked,
- {
- item: skill,
- score: Math.min(...scores),
- tieBreaker: `${displayLabel}\u0000${skill.name}`,
- },
- 20,
- );
- }
- }
-
- return ranked.map(({ item: skill }) => ({
- id: `skill:${skill.name}`,
- type: "skill" as const,
- skill,
- label: skill.displayName ?? skill.name,
- description: skill.shortDescription ?? skill.description ?? "",
- }));
- }
-
- if (composerTrigger.kind === "path") {
- return pathSearch.entries.map((entry) => {
- const parts = entry.path.split("/");
- return {
- id: `path:${entry.path}`,
- type: "path" as const,
- path: entry.path,
- kind: entry.kind,
- label: parts[parts.length - 1] ?? entry.path,
- description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
- };
- });
- }
-
- return [];
- }, [composerTrigger, pathSearch.entries, selectedProviderStatus]);
-
- // ── Handle command selection ──────────────────────────────
- const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props;
+ const { onSendMessage } = props;
const handleSend = useCallback(async () => {
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
@@ -591,48 +348,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
props.selectedThread.id,
props.selectedThread.title,
]);
- const handleCommandSelect = useCallback(
- (item: ComposerCommandItem) => {
- if (!composerTrigger) return;
-
- if (
- item.type === "slash-command" &&
- (item.command === "plan" || item.command === "default")
- ) {
- const result = replaceTextRange(
- draftMessage,
- composerTrigger.rangeStart,
- composerTrigger.rangeEnd,
- "",
- );
- setComposerSelection({ start: result.cursor, end: result.cursor });
- onChangeDraftMessage(result.text);
- onUpdateInteractionMode(item.command);
- return;
- }
-
- let replacement = "";
- if (item.type === "path") {
- replacement = `${serializeComposerFileLink(item.path)} `;
- } else if (item.type === "skill") {
- replacement = `$${item.skill.name} `;
- } else if (item.type === "slash-command") {
- replacement = `/${item.command} `;
- } else if (item.type === "provider-slash-command") {
- replacement = `/${item.command.name} `;
- }
-
- const result = replaceTextRange(
- draftMessage,
- composerTrigger.rangeStart,
- composerTrigger.rangeEnd,
- replacement,
- );
- setComposerSelection({ start: result.cursor, end: result.cursor });
- onChangeDraftMessage(result.text);
- },
- [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode],
- );
// ── Model menu ───────────────────────────────────────────
const modelOptions = useMemo(
@@ -664,6 +379,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
const settingsRouteSession = useMemo(
() => ({
ownerId: settingsOwnerId,
+ environmentId: props.environmentId,
providerGroups: threadProviderGroups,
selectedModel: currentModelSelection,
onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
@@ -739,26 +455,21 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
silently drops experimental_backgroundImage on Android, which left this
strip fully transparent and the feed text legible through the composer. */}
- {composerTrigger && composerMenuItems.length > 0 ? (
+ {composerMenu.trigger && composerMenu.items.length > 0 ? (
) : null}
@@ -771,7 +482,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) : null}
void props.onNativePasteImages(uris)}
placeholder={props.placeholder}
onFocus={handleFocus}
@@ -881,11 +591,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) : null}
{isExpanded ? (
-
+
void;
}) {
- const codeBackground = useThemeColor("--color-md-code-bg");
const [availableWidth, setAvailableWidth] = useState(0);
const [sourceSize, setSourceSize] = useState<{ width: number; height: number } | null>(null);
const [failedUri, setFailedUri] = useState(null);
@@ -248,12 +247,9 @@ function ThreadMarkdownImageView(props: {
>
{props.uri === null || failed ? (
{failed ? (
@@ -271,13 +267,9 @@ function ThreadMarkdownImageView(props: {
style={{ alignSelf: "flex-start" }}
>
({
- background,
- border,
- mutedBackground,
- text,
- mutedText,
- codeBackground,
+ background: theme["--color-card"],
+ border: theme["--color-border"],
+ mutedBackground: theme["--color-subtle"],
+ text: theme["--color-foreground"],
+ mutedText: theme["--color-foreground-muted"],
+ codeBackground: theme["--color-md-code-bg"],
}),
- [background, border, codeBackground, mutedBackground, mutedText, text],
+ [theme],
);
}
@@ -596,25 +583,26 @@ function useMarkdownStyles(
[appearance.baseFontSize],
);
const themeMode = themeAppearance;
- const markdownBodyColor = String(useThemeColor("--color-md-body"));
- const markdownStrongColor = String(useThemeColor("--color-md-strong"));
- const markdownLinkColor = String(useThemeColor("--color-md-link"));
- const markdownBlockquoteBg = String(useThemeColor("--color-md-blockquote-bg"));
- const markdownBlockquoteBorder = String(useThemeColor("--color-md-blockquote-border"));
- const markdownCodeBg = String(useThemeColor("--color-md-code-bg"));
- const markdownCodeText = String(useThemeColor("--color-md-code-text"));
- const markdownInlineCodeText = String(useThemeColor("--color-foreground-secondary"));
- const markdownHrColor = String(useThemeColor("--color-md-hr"));
- const markdownUserBodyColor = String(useThemeColor("--color-user-bubble-foreground"));
- const markdownUserCodeBg = String(useThemeColor("--color-md-user-code-bg"));
- const markdownUserCodeText = String(useThemeColor("--color-md-user-code-text"));
- const markdownUserInlineCodeText = String(useThemeColor("--color-user-bubble-foreground-muted"));
- const markdownUserFenceBg = String(useThemeColor("--color-md-user-fence-bg"));
- const markdownUserFenceText = String(useThemeColor("--color-md-user-fence-text"));
- const iconSubtleColor = String(useThemeColor("--color-icon-subtle"));
- const inlineSkillForeground = String(useThemeColor("--color-inline-skill-foreground"));
- const userBubbleSkillForeground = String(useThemeColor("--color-user-bubble-skill-foreground"));
- const userBubbleForegroundMuted = String(useThemeColor("--color-user-bubble-foreground-muted"));
+ const theme = useUniwindTheme();
+ const markdownBodyColor = theme["--color-md-body"];
+ const markdownStrongColor = theme["--color-md-strong"];
+ const markdownLinkColor = theme["--color-md-link"];
+ const markdownBlockquoteBg = theme["--color-md-blockquote-bg"];
+ const markdownBlockquoteBorder = theme["--color-md-blockquote-border"];
+ const markdownCodeBg = theme["--color-md-code-bg"];
+ const markdownCodeText = theme["--color-md-code-text"];
+ const markdownInlineCodeText = theme["--color-foreground-secondary"];
+ const markdownHrColor = theme["--color-md-hr"];
+ const markdownUserBodyColor = theme["--color-user-bubble-foreground"];
+ const markdownUserCodeBg = theme["--color-md-user-code-bg"];
+ const markdownUserCodeText = theme["--color-md-user-code-text"];
+ const markdownUserInlineCodeText = theme["--color-user-bubble-foreground-muted"];
+ const markdownUserFenceBg = theme["--color-md-user-fence-bg"];
+ const markdownUserFenceText = theme["--color-md-user-fence-text"];
+ const iconSubtleColor = theme["--color-icon-subtle"];
+ const inlineSkillForeground = theme["--color-inline-skill-foreground"];
+ const userBubbleSkillForeground = theme["--color-user-bubble-skill-foreground"];
+ const userBubbleForegroundMuted = theme["--color-user-bubble-foreground-muted"];
const regularFontFamily = useFontFamily("regular");
const boldFontFamily = useFontFamily("bold");
@@ -1014,7 +1002,7 @@ function renderFeedEntry(
accessibilityState={{ expanded: entry.expanded }}
onPress={() => 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]"
+ className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
>
{entry.label}
@@ -1022,7 +1010,7 @@ function renderFeedEntry(
@@ -1046,7 +1034,9 @@ function renderFeedEntry(
const isUser = message.role === "user";
const styles = isUser ? markdownStyles.user : markdownStyles.assistant;
const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt);
- const attachments = message.attachments ?? [];
+ const attachments = (message.attachments ?? []).filter(
+ (attachment) => attachment.type === "image",
+ );
const hasReviewCommentContext = message.text.includes("
-
+
{timestampLabel}
{message.text.trim().length > 0 ? (
@@ -1161,7 +1151,7 @@ function renderFeedEntry(
key={attachment.id}
environmentId={props.environmentId}
attachmentId={attachment.id}
- className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-neutral-200 dark:bg-neutral-800"
+ className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800"
onPressImage={props.onPressImage}
/>
);
@@ -1175,7 +1165,7 @@ function renderFeedEntry(
buttonSize={28}
iconSize={13}
/>
-
+
{timestampLabel}
@@ -1211,11 +1201,11 @@ const WorkingTimelineRow = memo(function WorkingTimelineRow(props: { readonly st
return (
-
-
-
+
+
+
-
+
Working for {durationLabel}
@@ -1307,6 +1297,7 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
}) {
const { codeSurface, nativeReviewDiffStyle } = useAppearanceCodeSurface();
const { themeAppearance: appearanceScheme, themeId } = useAppearancePreferences();
+ const appTheme = useUniwindTheme();
const NativeReviewDiffView = resolveNativeReviewDiffView();
const patch = useMemo(() => buildReviewCommentPatch(props.comment), [props.comment]);
const parsedDiff = useMemo(
@@ -1319,8 +1310,8 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: {
[nativeReviewDiffData.rows],
);
const nativeReviewDiffTheme = useMemo(
- () => createNativeReviewDiffTheme(appearanceScheme, themeId),
- [appearanceScheme, themeId],
+ () => createNativeReviewDiffTheme(appearanceScheme, themeId, appTheme),
+ [appearanceScheme, appTheme, themeId],
);
const nativeRowsJson = useMemo(() => JSON.stringify(compactNativeRows), [compactNativeRows]);
const nativeThemeJson = useMemo(
@@ -1582,8 +1573,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
? navigationHeaderHeight || insets.top + IOS_NAV_BAR_HEIGHT
: topContentInset;
- const iconSubtleColor = useThemeColor("--color-icon-subtle");
- const userBubbleColor = useThemeColor("--color-user-bubble");
+ const theme = useUniwindTheme();
+ const iconSubtleColor = theme["--color-icon-subtle"];
+ const userBubbleColor = theme["--color-user-bubble"];
const onMarkdownLinkPress = useCallback(
(href: string) => {
const presentation = resolveMarkdownLinkPresentation(href);
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 0e74e27f87..fd4d0b9d0a 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -27,7 +27,6 @@ import { SymbolView } from "../../components/AppSymbol";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
-import { useThemeColor } from "../../lib/useThemeColor";
import { useProjects, useThreadShells } from "../../state/entities";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
@@ -127,19 +126,11 @@ export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) {
}
function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
- const backgroundColor = useThemeColor("--color-drawer");
- const borderColor = useThemeColor("--color-border");
-
return (
@@ -731,10 +722,6 @@ function ThreadNavigationSidebarPane(
],
);
- const backgroundColor = useThemeColor("--color-drawer");
- const borderColor = useThemeColor("--color-border");
- const mutedColor = useThemeColor("--color-foreground-muted");
- const placeholderColor = useThemeColor("--color-placeholder");
const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState(null);
// The sticky header (title row, search field, optional connection status)
// is measured so the list inset always matches its real height — no
@@ -1242,13 +1229,8 @@ function ThreadNavigationSidebarPane(
return (
@@ -1286,14 +1268,11 @@ function ThreadNavigationSidebarPane(
{/* Title slot doubles as the connection status surface: while an
@@ -1318,7 +1297,12 @@ function ThreadNavigationSidebarPane(
-
+
-
- {props.option.label}
-
- {props.option.isDefault ? (
-
- Default
-
- ) : null}
- {props.option.isLegacy ? (
-
- Legacy
+
+
+
+ {props.option.label}
+
+ {props.option.isDefault ? (
+
+ Default
+
+ ) : null}
+ {props.option.isLegacy ? (
+
+ Legacy
+
+ ) : null}
- ) : null}
-
+ {props.option.subtitle ? (
+
+ {props.option.subtitle}
+
+ ) : null}
+
{props.selected ? (
@@ -140,7 +157,6 @@ function ProviderHeader(props: {
readonly modelCount: number;
readonly onToggle: () => void;
}) {
- const iconSubtle = useThemeColor("--color-icon-subtle");
const content = (
<>
@@ -156,7 +172,7 @@ function ProviderHeader(props: {
>
@@ -192,7 +208,6 @@ function DisclosureRow(props: {
readonly onPress: () => void;
readonly isLast?: boolean;
}) {
- const iconSubtle = useThemeColor("--color-icon-subtle");
return (
) : null}
-
+
);
}
@@ -222,7 +242,6 @@ function ChoiceRow(props: {
readonly onPress: () => void;
readonly isLast: boolean;
}) {
- const checkmarkColor = useThemeColor("--color-icon");
return (
@@ -281,6 +300,7 @@ type ThreadSettingsSubmenuPage =
| { readonly kind: "runtime" };
type ThreadSettingsSessionProps = {
+ readonly environmentId: EnvironmentId | null;
readonly providerGroups: ReadonlyArray;
readonly selectedModel: ModelSelection | null;
readonly onSelectModel: (option: ModelOption) => void;
@@ -332,6 +352,7 @@ export function useExistingThreadSettingsRoutePresentation() {
}
type ThreadSettingsSessionValue = {
+ readonly environmentId: EnvironmentId | null;
readonly providerGroups: ReadonlyArray;
readonly runtimeMode: RuntimeMode;
readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
@@ -450,6 +471,7 @@ function ThreadSettingsSessionProvider(
const value = useMemo(
() => ({
+ environmentId: props.environmentId,
providerGroups: props.providerGroups,
runtimeMode: props.runtimeMode,
onUpdateRuntimeMode: props.onUpdateRuntimeMode,
@@ -478,6 +500,7 @@ function ThreadSettingsSessionProvider(
hasLegacyModels,
isApplied,
isDisplayed,
+ props.environmentId,
pendingModel,
pressModel,
providerFilter,
@@ -946,6 +969,23 @@ function ThreadSettingsModelsScreen() {
const navigation = useNavigation>();
const usesNativeMailSearchToolbar = Platform.OS === "ios" && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED;
const hasCustomCatalogFilter = session.providerFilter !== null || session.showLegacy;
+ const refreshProvidersCommand = useAtomCommand(serverEnvironment.refreshProviders, {
+ reportFailure: false,
+ });
+ const refreshProviderCatalog = useMemo(
+ () => createProviderCatalogRefreshRunner(refreshProvidersCommand),
+ [refreshProvidersCommand],
+ );
+ const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
+ const refreshProviders = useCallback(() => {
+ if (!session.environmentId || isRefreshingProviders) return;
+ setIsRefreshingProviders(true);
+ void refreshProviderCatalog(session.environmentId).then((result) => {
+ setIsRefreshingProviders(false);
+ const error = providerCatalogRefreshError(result);
+ if (error) Alert.alert("Could not refresh models", error);
+ });
+ }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
const commitAndClose = useCallback(() => {
session.commitPendingModel();
presentation.onClose();
@@ -993,6 +1033,12 @@ function ThreadSettingsModelsScreen() {
{Platform.OS === "android" ? (
+
({
onClose: props.onClose,
@@ -1217,6 +1271,7 @@ export function NewTaskThreadSettingsRouteScreen() {
return (
flow.setSelectedModelKey(option.key, option.selection.options)}
diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx
index cdc7f1a64a..f263372bad 100644
--- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx
@@ -85,7 +85,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) {
{isDefaultRef ? (
-
+
Warning: this is the default branch.
) : null}
diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
index 17e4de0ab6..5aefccb4ba 100644
--- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
@@ -17,7 +17,7 @@ import { Alert, Platform, Pressable, RefreshControl, ScrollView, View } from "re
import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { useThemeColor } from "../../../lib/useThemeColor";
+import { useUniwindTheme } from "../../../lib/useUniwindTheme";
import { AndroidSheetHeader } from "../../../components/AndroidScreenHeader";
import { AppText as Text } from "../../../components/AppText";
@@ -53,10 +53,9 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
const { selectedThreadCwd, selectedThreadWorktreePath } = useSelectedThreadWorktree();
const gitState = useSelectedThreadGitState();
const gitActions = useSelectedThreadGitActions();
-
- const iconColor = useThemeColor("--color-icon");
- const foregroundColor = String(useThemeColor("--color-foreground"));
- const sheetColor = String(useThemeColor("--color-sheet"));
+ const theme = useUniwindTheme();
+ const foregroundColor = theme["--color-foreground"];
+ const sheetColor = theme["--color-sheet"];
const gitStatus = useEnvironmentQuery(
selectedThread !== null && selectedThreadCwd !== null
@@ -385,7 +384,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
diff --git a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx
index 61346fcef0..285c3414a9 100644
--- a/apps/mobile/src/features/threads/git/gitSheetComponents.tsx
+++ b/apps/mobile/src/features/threads/git/gitSheetComponents.tsx
@@ -1,7 +1,6 @@
import { SymbolView } from "../../../components/AppSymbol";
import type { ComponentProps } from "react";
import { Pressable, View } from "react-native";
-import { useThemeColor } from "../../../lib/useThemeColor";
import { AppText as Text } from "../../../components/AppText";
import { cn } from "../../../lib/cn";
@@ -14,12 +13,13 @@ export function SheetActionButton(props: {
readonly tone?: "primary" | "secondary" | "danger";
readonly onPress: () => void;
}) {
- const primaryFg = useThemeColor("--color-primary-foreground");
- const dangerFg = useThemeColor("--color-danger-foreground");
- const secondaryFg = useThemeColor("--color-secondary-foreground");
-
const tone = props.tone ?? "secondary";
- const textColor = tone === "primary" ? primaryFg : tone === "danger" ? dangerFg : secondaryFg;
+ const textColorClassName =
+ tone === "primary"
+ ? "accent-primary-foreground"
+ : tone === "danger"
+ ? "accent-danger-foreground"
+ : "accent-secondary-foreground";
return (
-
+
void;
}) {
- const iconColor = useThemeColor("--color-icon");
- const iconSubtleColor = useThemeColor("--color-icon-subtle");
-
return (
-
+
{props.title}
@@ -89,7 +96,12 @@ export function SheetListRow(props: {
{props.subtitle}
) : null}
-
+
);
}
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 14f0fcc95a..dc94adb419 100644
--- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx
+++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx
@@ -7,7 +7,7 @@ import type {
ProviderInteractionMode,
ProviderOptionSelection,
RuntimeMode,
- ServerProviderSkill,
+ ServerProvider,
} from "@t3tools/contracts";
import {
CommandId,
@@ -33,6 +33,7 @@ import {
buildModelOptions,
groupByProvider,
resolveDefaultableModelSelection,
+ resolveNewTaskModelSelection,
resolveSelectableModelSelection,
} from "../../lib/modelOptions";
import { scopedProjectKey } from "../../lib/scopedEntities";
@@ -48,8 +49,10 @@ import {
removeComposerDraftAttachment,
replaceComposerDraftAttachments,
setComposerDraftText,
+ setStickyComposerModelSelection,
updateComposerDraftSettings,
useComposerDraft,
+ useStickyComposerModelSelection,
} from "../../state/use-composer-drafts";
import { useDebouncedValue, usePaginatedBranches } from "../../state/queries";
import { vcsEnvironment } from "../../state/vcs";
@@ -153,7 +156,7 @@ type NewTaskFlowContextValue = {
readonly modelOptions: ReadonlyArray;
readonly selectedModel: ModelSelection | null;
readonly selectedModelOption: ModelOption | null;
- readonly selectedProviderSkills: ReadonlyArray;
+ readonly selectedProviderStatus: ServerProvider | null;
readonly providerGroups: ReadonlyArray;
readonly filteredBranches: ReadonlyArray;
readonly reset: () => void;
@@ -418,21 +421,33 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedEnvironmentServerConfig,
selectedProject?.defaultModelSelection ?? null,
);
+ const storedStickyModelSelection = useStickyComposerModelSelection();
+ const stickyModelSelection = resolveDefaultableModelSelection(
+ selectedEnvironmentServerConfig,
+ storedStickyModelSelection,
+ );
const modelOptions = useMemo(
() =>
buildModelOptions(
selectedEnvironmentServerConfig,
- draftModelSelection ?? projectDefaultModelSelection,
+ draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
),
- [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection],
+ [
+ selectedEnvironmentServerConfig,
+ draftModelSelection,
+ projectDefaultModelSelection,
+ stickyModelSelection,
+ ],
);
- const selectedModel =
- draftModelSelection ??
- projectDefaultModelSelection ??
- modelOptions.find((option) => option.isDefault)?.selection ??
- modelOptions[0]?.selection ??
- null;
+ // An unsent draft keeps its explicit pick. Fresh drafts resolve the project
+ // default before the last manual app-wide selection and provider default.
+ const selectedModel = resolveNewTaskModelSelection({
+ draftSelection: draftModelSelection,
+ projectDefaultSelection: projectDefaultModelSelection,
+ stickySelection: stickyModelSelection,
+ modelOptions,
+ });
const selectedModelKey = selectedModel
? `${selectedModel.instanceId}:${selectedModel.model}`
: null;
@@ -444,11 +459,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
option.selection.instanceId === selectedModel.instanceId &&
option.selection.model === selectedModel.model,
) ?? null;
- const selectedProviderSkills = useMemo(
+ const selectedProviderStatus = useMemo(
() =>
selectedEnvironmentServerConfig?.providers.find(
(provider) => provider.instanceId === selectedModel?.instanceId,
- )?.skills ?? [],
+ ) ?? null,
[selectedEnvironmentServerConfig, selectedModel?.instanceId],
);
const setSelectedModelKey = useCallback(
@@ -462,9 +477,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
if (!option) {
return;
}
- updateComposerDraftSettings(selectedProjectDraftKey, {
- modelSelection: options ? { ...option.selection, options } : option.selection,
- });
+ const selection = options ? { ...option.selection, options } : option.selection;
+ updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection });
+ setStickyComposerModelSelection(selection);
},
[modelOptions, selectedProjectDraftKey],
);
@@ -482,6 +497,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
updateComposerDraftSettings(selectedProjectDraftKey, {
modelSelection: nextSelection,
});
+ setStickyComposerModelSelection(nextSelection);
},
[selectedModel, selectedProjectDraftKey],
);
@@ -1028,7 +1044,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
modelOptions,
selectedModel,
selectedModelOption,
- selectedProviderSkills,
+ selectedProviderStatus,
providerGroups,
filteredBranches,
reset,
@@ -1090,7 +1106,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedModelKey,
selectedModelOption,
selectedProjectDraftKey,
- selectedProviderSkills,
+ selectedProviderStatus,
setSelectedModelOptions,
selectedProject,
selectedProjectKey,
diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts
new file mode 100644
index 0000000000..565a540744
--- /dev/null
+++ b/apps/mobile/src/features/threads/provider-catalog-refresh.test.ts
@@ -0,0 +1,46 @@
+import { EnvironmentId } from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import { AsyncResult } from "effect/unstable/reactivity";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ createProviderCatalogRefreshRunner,
+ providerCatalogRefreshError,
+} from "./provider-catalog-refresh";
+
+describe("mobile provider catalog refresh", () => {
+ it("calls server discovery for the selected environment and deduplicates pending taps", async () => {
+ let resolveRefresh: ((value: "refreshed") => void) | undefined;
+ const refreshProviders = vi.fn(
+ () =>
+ new Promise<"refreshed">((resolve) => {
+ resolveRefresh = resolve;
+ }),
+ );
+ const refresh = createProviderCatalogRefreshRunner(refreshProviders);
+ const environmentId = EnvironmentId.make("environment-mobile");
+
+ const first = refresh(environmentId);
+ const second = refresh(environmentId);
+
+ expect(second).toBe(first);
+ expect(refreshProviders).toHaveBeenCalledOnce();
+ expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} });
+
+ resolveRefresh?.("refreshed");
+ await expect(first).resolves.toBe("refreshed");
+ });
+
+ it("reports a discovery error and allows retry after the failed command settles", async () => {
+ const failure = AsyncResult.failure(Cause.fail(new Error("discovery failed")));
+ const success = AsyncResult.success("refreshed");
+ let callCount = 0;
+ const refreshProviders = vi.fn(async () => (callCount++ === 0 ? failure : success));
+ const refresh = createProviderCatalogRefreshRunner(refreshProviders);
+ const environmentId = EnvironmentId.make("environment-mobile");
+
+ expect(providerCatalogRefreshError(await refresh(environmentId))).toBe("discovery failed");
+ expect(providerCatalogRefreshError(await refresh(environmentId))).toBeNull();
+ expect(refreshProviders).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/apps/mobile/src/features/threads/provider-catalog-refresh.ts b/apps/mobile/src/features/threads/provider-catalog-refresh.ts
new file mode 100644
index 0000000000..3e79a5b1c7
--- /dev/null
+++ b/apps/mobile/src/features/threads/provider-catalog-refresh.ts
@@ -0,0 +1,34 @@
+import type { EnvironmentId } from "@t3tools/contracts";
+import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
+import {
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+
+type RefreshProvidersTarget = {
+ readonly environmentId: EnvironmentId;
+ readonly input: Record;
+};
+
+/** Deduplicates taps while the server refresh command is still running. */
+export function createProviderCatalogRefreshRunner(
+ refreshProviders: (target: RefreshProvidersTarget) => Promise,
+) {
+ let pending: Promise | null = null;
+
+ return (environmentId: EnvironmentId): Promise => {
+ if (pending) return pending;
+ pending = refreshProviders({ environmentId, input: {} }).finally(() => {
+ pending = null;
+ });
+ return pending;
+ };
+}
+
+export function providerCatalogRefreshError(
+ result: AtomCommandResult,
+): string | null {
+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return null;
+ const error = squashAtomCommandFailure(result);
+ return error instanceof Error ? error.message : "Provider discovery failed.";
+}
diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx
index 1895ef0d45..61ab1fb41f 100644
--- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx
+++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx
@@ -1,8 +1,6 @@
import { SymbolView } from "../../components/AppSymbol";
import { Pressable } from "react-native";
-import { useThemeColor } from "../../lib/useThemeColor";
-
export type SidebarFilterButtonIcon =
| "line.3.horizontal.decrease.circle"
| "line.3.horizontal.decrease.circle.fill";
@@ -11,8 +9,6 @@ export function SidebarFilterButton(props: {
readonly accessibilityLabel: string;
readonly icon: SidebarFilterButtonIcon;
}) {
- const iconColor = useThemeColor("--color-foreground");
-
return (
-
+
);
}
diff --git a/apps/mobile/src/features/threads/sidebar-header-actions.tsx b/apps/mobile/src/features/threads/sidebar-header-actions.tsx
index 9ce77f8991..52fb8c6999 100644
--- a/apps/mobile/src/features/threads/sidebar-header-actions.tsx
+++ b/apps/mobile/src/features/threads/sidebar-header-actions.tsx
@@ -1,8 +1,6 @@
import { SymbolView } from "../../components/AppSymbol";
import { Pressable, View } from "react-native";
-import { useThemeColor } from "../../lib/useThemeColor";
-
export interface SidebarHeaderActionsProps {
readonly onOpenSettings: () => void;
}
@@ -12,8 +10,6 @@ function FallbackHeaderButton(props: {
readonly icon: "gearshape" | "square.and.pencil";
readonly onPress: () => void;
}) {
- const iconColor = useThemeColor("--color-foreground");
-
return (
-
+
);
}
diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx
index d5e09b07e1..e9e204e477 100644
--- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx
+++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx
@@ -10,7 +10,6 @@ import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme";
-import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
const SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
@@ -53,8 +52,7 @@ const SidebarStack = createNativeStackNavigator();
* navigation hooks used for header configuration inside the pane.
*/
export function SidebarNavigationShell(props: { readonly children: ReactNode }) {
- const { themeAppearance } = useAppearancePreferences();
- const navigationTheme = useMobileNavigationTheme(themeAppearance);
+ const navigationTheme = useMobileNavigationTheme();
return (
diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx
index 78e6e43c07..df10e585aa 100644
--- a/apps/mobile/src/features/threads/thread-list-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-items.tsx
@@ -19,7 +19,7 @@ import { cn } from "../../lib/cn";
import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics";
import { relativeTime } from "../../lib/time";
import { themeColorWithAlpha } from "../../lib/mobileTheme";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr";
import type { HomeGroupDisplayAction } from "../home/homeListItems";
@@ -87,7 +87,6 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
readonly newThreadTarget?: EnvironmentProject | null;
readonly onNewThread?: (project: EnvironmentProject) => void;
}) {
- const iconMutedColor = useThemeColor("--color-icon-muted");
const { groupKey, onGroupAction, onNewThread } = props;
const newThreadTarget = props.newThreadTarget ?? null;
const compact = props.variant === "compact";
@@ -171,7 +170,7 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
@@ -190,7 +189,6 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:
readonly groupKey: string;
readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
}) {
- const iconSubtleColor = useThemeColor("--color-icon-subtle");
const showsMore = props.hiddenCount > 0;
const compact = props.variant === "compact";
const { groupKey, onGroupAction } = props;
@@ -221,7 +219,7 @@ export const ThreadListShowMoreRow = memo(function ThreadListShowMoreRow(props:
@@ -275,10 +273,9 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
}) {
const compact = props.variant === "compact";
- const separatorColor = useThemeColor("--color-separator");
- const iconSubtleColor = useThemeColor("--color-icon-subtle");
- const mutedColor = useThemeColor("--color-foreground-muted");
- const pressedBackgroundColor = useThemeColor("--color-subtle");
+ const theme = useUniwindTheme();
+ const separatorColor = theme["--color-separator"];
+ const pressedBackgroundColor = theme["--color-subtle"];
const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
const timestamp = relativeTime(pendingTask.message.createdAt);
@@ -294,8 +291,8 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
);
const statusPill = (
-
- Pending
+
+ Pending
);
@@ -305,7 +302,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: {
@@ -446,13 +443,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
// thread, so a hover highlight can't leak across rows.
const [hovered, setHovered] = useRecyclingState(false);
- const separatorColor = useThemeColor("--color-separator");
- const iconSubtleColor = useThemeColor("--color-icon-subtle");
- const screenColor = useThemeColor("--color-screen");
- const drawerColor = useThemeColor("--color-drawer");
- const pressedBackgroundColor = useThemeColor("--color-subtle");
- const selectedBackgroundColor = useThemeColor("--color-user-bubble");
- const selectedForegroundColor = useThemeColor("--color-user-bubble-foreground");
+ const theme = useUniwindTheme();
+ const separatorColor = theme["--color-separator"];
+ const screenColor = theme["--color-screen"];
+ const drawerColor = theme["--color-drawer"];
+ const pressedBackgroundColor = theme["--color-subtle"];
+ const selectedBackgroundColor = theme["--color-user-bubble"];
+ const selectedForegroundColor = theme["--color-user-bubble-foreground"];
const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } =
props;
@@ -600,7 +597,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: {
diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
index 1467792800..51b1ed7afc 100644
--- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx
+++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx
@@ -16,7 +16,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon";
import { ProviderIcon } from "../../components/ProviderIcon";
import { cn } from "../../lib/cn";
import { relativeTime } from "../../lib/time";
-import { useThemeColor } from "../../lib/useThemeColor";
+import { useUniwindTheme } from "../../lib/useUniwindTheme";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { useThreadPr } from "../../state/use-thread-pr";
import { ThreadSwipeable } from "../home/thread-swipe-actions";
@@ -52,10 +52,10 @@ const MONO_FONT = Platform.select({
const STATUS_LABEL_BY_STATUS: Partial<
Record
> = {
- approval: { label: "Approval", className: "text-amber-700 dark:text-amber-300" },
- input: { label: "Input", className: "text-indigo-600 dark:text-indigo-300" },
- working: { label: "Working", className: "text-sky-600 dark:text-sky-400" },
- failed: { label: "Failed", className: "text-red-700 dark:text-red-300" },
+ approval: { label: "Approval", className: "text-adaptive-amber-700-300" },
+ input: { label: "Input", className: "text-adaptive-indigo-600-300" },
+ working: { label: "Working", className: "text-adaptive-sky-600-400" },
+ failed: { label: "Failed", className: "text-adaptive-red-700-300" },
};
function threadTimeLabel(thread: EnvironmentThreadShell): string {
@@ -93,7 +93,6 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid
readonly label: string;
readonly pane?: "screen" | "sidebar";
}) {
- const borderColor = useThemeColor("--color-border");
return (
{props.label}
-
+
);
});
@@ -134,10 +133,10 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS
onPress={props.onToggle}
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
-
+
{props.expanded ? "Snoozed" : `Snoozed (${props.count})`}
-
+
void;
readonly pane?: "screen" | "sidebar";
}) {
- const mutedColor = useThemeColor("--color-foreground-muted");
return (
@@ -213,8 +211,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
}) {
const { pendingTask, onSelectPendingTask, onDeletePendingTask } = props;
- const drawerColor = useThemeColor("--color-drawer");
- const pressedBackgroundColor = useThemeColor("--color-subtle");
+ const theme = useUniwindTheme();
+ const drawerColor = theme["--color-drawer"];
+ const pressedBackgroundColor = theme["--color-subtle"];
const sidebarPane = props.pane === "sidebar";
const projectTitle =
props.projectTitle ?? props.project?.title ?? pendingTask.creation.projectTitle ?? "";
@@ -418,11 +417,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
onChangeRequestState?.(threadKey, changeRequest);
}, [onChangeRequestState, prState, prUpdatedAt, thread.linkedPullRequest, threadKey]);
- const screenColor = useThemeColor("--color-screen");
- const drawerColor = useThemeColor("--color-drawer");
- const pressedBackgroundColor = useThemeColor("--color-subtle");
- const selectedBackgroundColor = useThemeColor("--color-user-bubble");
- const pinTintColor = useThemeColor("--color-foreground-muted");
+ const theme = useUniwindTheme();
+ const screenColor = theme["--color-screen"];
+ const drawerColor = theme["--color-drawer"];
+ const pressedBackgroundColor = theme["--color-subtle"];
+ const selectedBackgroundColor = theme["--color-user-bubble"];
const sidebarPane = props.pane === "sidebar";
const selected = props.selected === true;
@@ -699,7 +698,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
{props.projectTitle ?? props.project?.title ?? ""}
{pinnedRow ? (
-
+
) : null}
@@ -905,7 +907,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
selected
? "text-user-bubble-foreground-muted"
: snoozedRow
- ? "text-blue-600 dark:text-blue-400"
+ ? "text-adaptive-blue-600-400"
: "text-foreground-tertiary",
)}
style={{ fontFamily: MONO_FONT }}
diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx
index da80ca0766..48aaf80249 100644
--- a/apps/mobile/src/features/threads/thread-search-match.tsx
+++ b/apps/mobile/src/features/threads/thread-search-match.tsx
@@ -65,8 +65,8 @@ export function ThreadSearchMatchExcerpt(props: {
props.selected
? "text-user-bubble-foreground"
: isUser
- ? "text-blue-500 dark:text-blue-400"
- : "text-emerald-600 dark:text-emerald-400",
+ ? "text-adaptive-blue-500-400"
+ : "text-adaptive-emerald-600-400",
)}
>
{isUser ? "You:" : "Agent:"}{" "}
diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts
index 2e8fee9857..5c6e25f437 100644
--- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts
+++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts
@@ -12,7 +12,7 @@ function modelOption(
return {
key: `codex:${model}`,
label: model,
- subtitle: "Codex",
+ subtitle: "",
providerKey: "codex",
providerLabel: "Codex",
providerDriver: "codex",
@@ -48,6 +48,21 @@ describe("thread settings sheet state", () => {
).toBe(true);
});
+ it("matches the upstream provider's display name", () => {
+ const model = {
+ ...modelOption("opencode/claude-fable-5"),
+ label: "Claude Fable 5",
+ subtitle: "OpenCode Zen",
+ };
+
+ expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: " ZEN " })).toBe(
+ true,
+ );
+ expect(modelMatchesCatalogQuery({ model, providerLabel: "OpenCode", query: "copilot" })).toBe(
+ false,
+ );
+ });
+
it("clears staging when the applied model is pressed", () => {
expect(
pendingModelAfterPress({
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index a5adacb8d1..192d82f92c 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -7,7 +7,6 @@ import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
-import { useThemeColor } from "../../lib/useThemeColor";
import Animated, { FadeIn } from "react-native-reanimated";
const WORK_LOG_LAYOUT_ANIMATION = {
@@ -128,7 +127,6 @@ export function ThreadWorkLog(props: {
readonly onCopyRow: (rowId: string, value: string) => void;
readonly onToggleRow: (rowId: string) => void;
}) {
- const pressedBackground = useThemeColor("--color-subtle");
const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
...activity,
detail: compactActivityDetail(activity.detail),
@@ -178,10 +176,7 @@ export function ThreadWorkLog(props: {
}
}}
onLongPress={() => props.onCopyRow(row.id, row.getCopyText())}
- style={({ pressed }) => ({
- backgroundColor: pressed ? pressedBackground : "transparent",
- })}
- className="rounded-md px-0.5 py-0"
+ className="rounded-md px-0.5 py-0 active:bg-subtle"
>
@@ -198,7 +193,7 @@ export function ThreadWorkLog(props: {
{row.summary}
@@ -210,7 +205,7 @@ export function ThreadWorkLog(props: {
{props.copiedRowId === row.id ? (
-
+
Copied
) : null}
@@ -239,7 +234,7 @@ export function ThreadWorkLog(props: {
: { ios: "minus", android: "remove" }
}
size={11}
- tintColor={row.status === "failure" ? "#e11d48" : props.iconSubtleColor}
+ tintColor={props.iconSubtleColor}
type="monochrome"
/>
) : null}
@@ -249,7 +244,7 @@ export function ThreadWorkLog(props: {
{fullDetail ? (
-
+
void;
}) {
- const pressedBackground = useThemeColor("--color-subtle");
const noun = props.onlyToolActivities
? props.hiddenCount === 1
? "tool call"
@@ -305,10 +299,7 @@ export function ThreadWorkGroupToggle(props: {
void Haptics.selectionAsync();
props.onToggle();
}}
- style={({ pressed }) => ({
- backgroundColor: pressed ? pressedBackground : "transparent",
- })}
- className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0"
+ className="min-h-8 flex-row items-center gap-1.5 rounded-md px-0.5 py-0 active:bg-subtle"
>
void;
+ readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void;
+}) {
+ const [selection, setSelection] = useState(() => ({
+ start: draftMessage.length,
+ end: draftMessage.length,
+ }));
+ const onSelectionChange = useCallback((nextSelection: ComposerEditorSelection) => {
+ setSelection(nextSelection);
+ }, []);
+ useEffect(() => {
+ const end = draftMessage.length;
+ setSelection((current) => {
+ const start = Math.min(current.start, end);
+ const selectionEnd = Math.min(current.end, end);
+ if (start === current.start && selectionEnd === current.end) {
+ return current;
+ }
+ return { start, end: selectionEnd };
+ });
+ }, [draftMessage.length]);
+
+ const trigger = useMemo(() => {
+ if (!enabled || selection.start !== selection.end) {
+ return null;
+ }
+ return detectComposerTrigger(draftMessage, selection.end);
+ }, [draftMessage, enabled, selection]);
+ const pathSearch = useComposerPathSearch({
+ environmentId,
+ cwd: trigger?.kind === "path" ? projectCwd : null,
+ query: trigger?.kind === "path" ? trigger.query : null,
+ });
+
+ const items = useMemo(() => {
+ if (!trigger) return [];
+
+ if (trigger.kind === "slash-command") {
+ const q = trigger.query.toLowerCase();
+ const allBuiltIn = [
+ {
+ id: "cmd:model",
+ type: "slash-command" as const,
+ command: "model",
+ label: "/model",
+ description: "Switch model",
+ },
+ {
+ id: "cmd:plan",
+ type: "slash-command" as const,
+ command: "plan",
+ label: "/plan",
+ description: "Switch to plan mode",
+ },
+ {
+ id: "cmd:default",
+ type: "slash-command" as const,
+ command: "default",
+ label: "/default",
+ description: "Switch to default mode",
+ },
+ ...(hasThread && selectedProviderStatus?.driver === "codex"
+ ? [
+ {
+ id: "cmd:goal",
+ type: "slash-command" as const,
+ command: "goal",
+ label: "/goal",
+ description: "Set, view, pause, resume, or clear a task goal",
+ },
+ ]
+ : []),
+ ];
+ const builtIn = allBuiltIn.filter(
+ (item) =>
+ item.command.includes(q) &&
+ (item.command === "model" || onUpdateInteractionMode !== undefined),
+ );
+
+ const providerCommands: ComposerCommandItem[] = [];
+ for (const command of selectedProviderStatus?.slashCommands ?? []) {
+ if (!command.name.toLowerCase().includes(q)) continue;
+ // Codex feedback uploads an existing thread's session and logs.
+ if (
+ !hasThread &&
+ selectedProviderStatus?.driver === "codex" &&
+ command.name === "feedback"
+ ) {
+ continue;
+ }
+ providerCommands.push({
+ id: `pcmd:${command.name}`,
+ type: "provider-slash-command",
+ command,
+ label: `/${command.name}`,
+ description: command.description ?? "",
+ });
+ }
+
+ const skillItems = (selectedProviderStatus?.skills ?? [])
+ .filter((skill) => matchesSlashSkillQuery(skill, q))
+ .map((skill) => ({
+ id: `skill:${skill.name}`,
+ type: "skill" as const,
+ skill,
+ label: `skill:${skill.name}`,
+ description: skill.shortDescription ?? skill.description ?? "",
+ }));
+
+ return [...builtIn, ...providerCommands, ...skillItems];
+ }
+
+ if (trigger.kind === "skill") {
+ const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled);
+ const normalizedQuery = normalizeSearchQuery(trigger.query, {
+ trimLeadingPattern: /^\$+/,
+ });
+
+ if (!normalizedQuery) {
+ return enabledSkills.slice(0, 20).map((skill) => ({
+ id: `skill:${skill.name}`,
+ type: "skill" as const,
+ skill,
+ label: skill.displayName ?? skill.name,
+ description: skill.shortDescription ?? skill.description ?? "",
+ }));
+ }
+
+ const ranked: Array<{
+ item: (typeof enabledSkills)[number];
+ score: number;
+ tieBreaker: string;
+ }> = [];
+ for (const skill of enabledSkills) {
+ const displayLabel = (skill.displayName ?? skill.name).toLowerCase();
+ const scores = [
+ scoreQueryMatch({
+ value: skill.name.toLowerCase(),
+ query: normalizedQuery,
+ exactBase: 0,
+ prefixBase: 2,
+ boundaryBase: 4,
+ includesBase: 6,
+ fuzzyBase: 100,
+ boundaryMarkers: ["-", "_", "/"],
+ }),
+ scoreQueryMatch({
+ value: displayLabel,
+ query: normalizedQuery,
+ exactBase: 1,
+ prefixBase: 3,
+ boundaryBase: 5,
+ includesBase: 7,
+ fuzzyBase: 110,
+ }),
+ scoreQueryMatch({
+ value: skill.shortDescription?.toLowerCase() ?? "",
+ query: normalizedQuery,
+ exactBase: 20,
+ prefixBase: 22,
+ boundaryBase: 24,
+ includesBase: 26,
+ }),
+ scoreQueryMatch({
+ value: skill.description?.toLowerCase() ?? "",
+ query: normalizedQuery,
+ exactBase: 30,
+ prefixBase: 32,
+ boundaryBase: 34,
+ includesBase: 36,
+ }),
+ ].filter((score): score is number => score !== null);
+
+ if (scores.length > 0) {
+ insertRankedSearchResult(
+ ranked,
+ {
+ item: skill,
+ score: Math.min(...scores),
+ tieBreaker: `${displayLabel}\u0000${skill.name}`,
+ },
+ 20,
+ );
+ }
+ }
+
+ return ranked.map(({ item: skill }) => ({
+ id: `skill:${skill.name}`,
+ type: "skill" as const,
+ skill,
+ label: skill.displayName ?? skill.name,
+ description: skill.shortDescription ?? skill.description ?? "",
+ }));
+ }
+
+ if (trigger.kind === "path") {
+ return pathSearch.entries.map((entry) => {
+ const parts = entry.path.split("/");
+ return {
+ id: `path:${entry.path}`,
+ type: "path" as const,
+ path: entry.path,
+ kind: entry.kind,
+ label: parts[parts.length - 1] ?? entry.path,
+ description: parts.length > 1 ? parts.slice(0, -1).join("/") : "",
+ };
+ });
+ }
+
+ return [];
+ }, [hasThread, onUpdateInteractionMode, pathSearch.entries, selectedProviderStatus, trigger]);
+
+ const onSelect = useCallback(
+ (item: ComposerCommandItem) => {
+ if (!trigger) return;
+
+ if (
+ item.type === "slash-command" &&
+ (item.command === "plan" || item.command === "default")
+ ) {
+ const result = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
+ setSelection({ start: result.cursor, end: result.cursor });
+ onChangeDraftMessage(result.text);
+ onUpdateInteractionMode?.(item.command);
+ return;
+ }
+
+ let replacement = "";
+ if (item.type === "path") {
+ replacement = `${serializeComposerFileLink(item.path)} `;
+ } else if (item.type === "skill") {
+ replacement = `$${item.skill.name} `;
+ } else if (item.type === "slash-command") {
+ replacement = `/${item.command} `;
+ } else if (item.type === "provider-slash-command") {
+ replacement = `/${item.command.name} `;
+ }
+
+ const result = replaceTextRange(
+ draftMessage,
+ trigger.rangeStart,
+ trigger.rangeEnd,
+ replacement,
+ );
+ setSelection({ start: result.cursor, end: result.cursor });
+ onChangeDraftMessage(result.text);
+ },
+ [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger],
+ );
+
+ return {
+ selection,
+ onSelectionChange,
+ trigger,
+ items,
+ isLoading: pathSearch.isPending,
+ onSelect,
+ };
+}
diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts
index 9a9ec5f228..2576ac21fb 100644
--- a/apps/mobile/src/features/usage/usageProviders.ts
+++ b/apps/mobile/src/features/usage/usageProviders.ts
@@ -5,21 +5,23 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
* Series and table order. The chart stacks providers from the bottom in this
* order, so it also fixes which band sits on top of the bars.
*/
-export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
+export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"];
export const PROVIDER_LABEL: Record = {
claude: "Claude Code",
codex: "Codex",
+ grok: "Grok Build",
};
/**
- * Claude's brand orange holds in both themes; Codex is neutral and must flip
- * with the theme or its bars vanish against the matching background.
+ * Claude's brand orange holds in both themes; Codex and Grok are neutrals and
+ * must flip with the theme or their bars vanish against the matching background.
*/
export function useProviderColors(): Record {
const { themeAppearance: scheme } = useAppearancePreferences();
return {
claude: "#d97757",
codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
+ grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
};
}
diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts
index 992beed3ab..d706d00fbe 100644
--- a/apps/mobile/src/lib/authClientMetadata.ts
+++ b/apps/mobile/src/lib/authClientMetadata.ts
@@ -8,7 +8,12 @@ export function authClientMetadata(appVersion?: string): AuthClientPresentationM
return {
label: "T3 Code Mobile",
- deviceType: "mobile",
+ deviceType:
+ Device.deviceType === Device.DeviceType.TABLET
+ ? "tablet"
+ : Device.deviceType === Device.DeviceType.PHONE
+ ? "mobile"
+ : "unknown",
...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
...(Number.isFinite(osMajorVersion) && osMajorVersion > 0 ? { osMajorVersion } : {}),
...(deviceModel ? { deviceModel } : {}),
diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts
index f474c7e6ea..6487e572c8 100644
--- a/apps/mobile/src/lib/connection.test.ts
+++ b/apps/mobile/src/lib/connection.test.ts
@@ -10,6 +10,14 @@ import { authClientMetadata } from "./authClientMetadata";
const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" }));
const mobileDevice = vi.hoisted(() => ({
+ deviceType: 1,
+ DeviceType: {
+ UNKNOWN: 0,
+ PHONE: 1,
+ TABLET: 2,
+ DESKTOP: 3,
+ TV: 4,
+ },
osVersion: "18.4.1",
modelName: "iPhone 15 Pro",
}));
@@ -29,6 +37,7 @@ vi.mock("expo-device", () => mobileDevice);
describe("mobile remote connection records", () => {
afterEach(() => {
mobilePlatform.OS = "ios";
+ mobileDevice.deviceType = mobileDevice.DeviceType.PHONE;
mobileDevice.osVersion = "18.4.1";
mobileDevice.modelName = "iPhone 15 Pro";
});
@@ -56,6 +65,17 @@ describe("mobile remote connection records", () => {
});
});
+ it("identifies native tablets separately from phones", () => {
+ mobileDevice.deviceType = mobileDevice.DeviceType.TABLET;
+ mobileDevice.modelName = "iPad Pro 13-inch";
+
+ expect(authClientMetadata()).toMatchObject({
+ deviceType: "tablet",
+ os: "iOS",
+ deviceModel: "iPad Pro 13-inch",
+ });
+ });
+
it("includes the mobile app version when the client provides it", () => {
expect(authClientMetadata("1.2.3")).toMatchObject({
surface: "mobile",
diff --git a/apps/mobile/src/lib/mobileBranding.test.ts b/apps/mobile/src/lib/mobileBranding.test.ts
deleted file mode 100644
index 48a84b3f98..0000000000
--- a/apps/mobile/src/lib/mobileBranding.test.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { describe, expect, it } from "vite-plus/test";
-
-import { resolveMobileStageLabel } from "./mobileBranding";
-
-describe("resolveMobileStageLabel", () => {
- it.each([
- ["development", "Dev"],
- ["preview", "Nightly"],
- ["production", "Alpha"],
- [undefined, "Alpha"],
- ])("maps %s builds to %s", (appVariant, expected) => {
- expect(resolveMobileStageLabel(appVariant)).toBe(expected);
- });
-});
diff --git a/apps/mobile/src/lib/mobileTheme.test-support.ts b/apps/mobile/src/lib/mobileTheme.test-support.ts
new file mode 100644
index 0000000000..a702bb9afb
--- /dev/null
+++ b/apps/mobile/src/lib/mobileTheme.test-support.ts
@@ -0,0 +1,20 @@
+import * as NodeFS from "node:fs";
+
+import type { MobileThemeAppearance, MobileThemeVariables } from "./mobileTheme";
+
+export function readDefaultMobileThemeVariables(
+ appearance: MobileThemeAppearance,
+): MobileThemeVariables {
+ const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8");
+ const variant = new RegExp(`@variant ${appearance} \\{([\\s\\S]*?)\\n \\}`, "u").exec(
+ stylesheet,
+ )?.[1];
+ if (variant === undefined) throw new Error(`Missing default ${appearance} theme in global.css.`);
+
+ return Object.fromEntries(
+ Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [
+ name,
+ value.trim(),
+ ]),
+ ) as MobileThemeVariables;
+}
diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts
index d5744952bb..a3c6712aba 100644
--- a/apps/mobile/src/lib/mobileTheme.test.ts
+++ b/apps/mobile/src/lib/mobileTheme.test.ts
@@ -1,8 +1,6 @@
import { describe, expect, it } from "vite-plus/test";
-import * as NodeFS from "node:fs";
-
import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes";
-import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme";
+import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";
import {
createMobileThemePairPatch,
@@ -11,7 +9,6 @@ import {
DEFAULT_MOBILE_THEME_ID,
getMobileThemePreviewColors,
getMobileThemeVariables,
- MOBILE_THEME_IDS,
normalizeMobileThemeId,
normalizeMobileThemeMode,
resolveMobileThemeIds,
@@ -52,13 +49,12 @@ function compositeOver(overlay: string, background: string): string {
describe("mobile themes", () => {
it("declares every runtime theme variable in the static stylesheet", () => {
- const stylesheet = NodeFS.readFileSync(new URL("../../global.css", import.meta.url), "utf8");
- const stylesheetVariables = new Set(
- Array.from(stylesheet.matchAll(/--color-[a-z0-9-]+/g), ([variable]) => variable),
+ const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light");
+ expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual(
+ Object.keys(generatedVariables).sort(),
);
-
- expect(Array.from(stylesheetVariables).sort()).toEqual(
- Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort(),
+ expect(Object.keys(readDefaultMobileThemeVariables("dark")).sort()).toEqual(
+ Object.keys(generatedVariables).sort(),
);
});
@@ -71,17 +67,11 @@ describe("mobile themes", () => {
});
it("preserves the existing mobile palette as the default", () => {
- expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")["--color-screen"]).toBe(
- "#f2f2f7",
+ expect(readDefaultMobileThemeVariables("light")["--color-screen"]).toBe("#f2f2f7");
+ expect(readDefaultMobileThemeVariables("dark")["--color-screen"]).toBe("#0a0a0a");
+ expect(readDefaultMobileThemeVariables("light")["--color-user-bubble-skill-foreground"]).toBe(
+ "#f0abfc",
);
- expect(getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "dark")["--color-screen"]).toBe(
- "#0a0a0a",
- );
- expect(
- getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, "light")[
- "--color-user-bubble-skill-foreground"
- ],
- ).toBe("#f0abfc");
});
it("applies palette overrides on top of the selected built-in theme", () => {
@@ -172,17 +162,11 @@ describe("mobile themes", () => {
expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)");
expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.12)");
expect(variables["--color-user-bubble-foreground"]).toMatch(/^#/);
- expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.light).sort()).toEqual(
- Object.keys(variables).sort(),
- );
- expect(Object.keys(DEFAULT_MOBILE_THEME_VARIABLES.dark).sort()).toEqual(
- Object.keys(variables).sort(),
- );
});
it("keeps every built-in shadow and backdrop black-based in dark mode", () => {
- for (const theme of BUILT_IN_THEMES) {
- const variables = getMobileThemeVariables(normalizeMobileThemeId(theme.id), "dark");
+ for (const themeId of BUILT_IN_THEME_IDS) {
+ const variables = getMobileThemeVariables(themeId, "dark");
expect(variables["--color-primary-shadow"]).toBe("#000000");
expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.48)");
expect(variables["--color-drawer-shadow"]).toBe("rgba(0, 0, 0, 0.32)");
@@ -190,7 +174,7 @@ describe("mobile themes", () => {
});
it("keeps placeholders and selected-row labels readable on their mobile surfaces", () => {
- for (const themeId of MOBILE_THEME_IDS) {
+ for (const themeId of BUILT_IN_THEME_IDS) {
for (const appearance of ["light", "dark"] as const) {
const variables = getMobileThemeVariables(themeId, appearance);
expect(
diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts
index 36de7f979d..2303451128 100644
--- a/apps/mobile/src/lib/mobileTheme.ts
+++ b/apps/mobile/src/lib/mobileTheme.ts
@@ -3,6 +3,7 @@ import {
getThemeColorsForAppearance,
MOBILE_DEFAULT_THEME_ID,
MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS,
+ type BuiltInThemeId,
type MobileThemeId as SharedMobileThemeId,
type ThemeAppearance,
type ThemeColors,
@@ -11,7 +12,6 @@ import {
STANDARD_THEME_PREVIEW_COLORS,
type ThemePreviewColors,
} from "@t3tools/shared/themePreview";
-import { DEFAULT_MOBILE_THEME_VARIABLES } from "./mobileDefaultTheme";
export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;
export const MOBILE_THEME_IDS = SHARED_MOBILE_THEME_IDS;
@@ -28,7 +28,7 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{
...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
];
-type MobileThemeVariable = `--color-${string}`;
+export type MobileThemeVariable = `--color-${string}`;
export type MobileThemeVariables = Readonly>;
export function normalizeMobileThemeId(value: unknown): MobileThemeId {
@@ -282,18 +282,18 @@ export function createMobileThemeVariables(
};
}
+export const MOBILE_THEME_VARIABLE_NAMES = Object.keys(
+ createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"),
+) as ReadonlyArray;
+
export function getMobileThemeVariables(
- themeId: MobileThemeId,
+ themeId: BuiltInThemeId,
appearance: MobileThemeAppearance,
overrides: Partial | null = null,
): MobileThemeVariables {
- const baseVariables = (() => {
- if (themeId === DEFAULT_MOBILE_THEME_ID) return DEFAULT_MOBILE_THEME_VARIABLES[appearance];
- const theme =
- BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
- const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
- return createMobileThemeVariables(colors, appearance);
- })();
+ const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0];
+ const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
+ const baseVariables = createMobileThemeVariables(colors, appearance);
// The complete base record guarantees that optional overrides cannot leave a token undefined.
return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables;
diff --git a/apps/mobile/src/lib/mobileThemeRuntime.test.ts b/apps/mobile/src/lib/mobileThemeRuntime.test.ts
new file mode 100644
index 0000000000..ad678d1f88
--- /dev/null
+++ b/apps/mobile/src/lib/mobileThemeRuntime.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ createMobileThemeRuntimeOperations,
+ getMobileUniwindThemeName,
+ type MobileThemeRuntimeState,
+} from "./mobileThemeRuntime";
+
+const initialState: MobileThemeRuntimeState = {
+ baseFontSize: 16,
+ themeAppearance: "light",
+ themeMode: "system",
+};
+
+describe("mobileThemeRuntime", () => {
+ it("keeps the default palette on Uniwind's built-in appearance themes", () => {
+ expect(getMobileUniwindThemeName("t3-code", "light")).toBe("light");
+ expect(getMobileUniwindThemeName("t3-code", "dark")).toBe("dark");
+ });
+
+ it("maps custom palettes and appearances to registered themes", () => {
+ expect(getMobileUniwindThemeName("t3-chat", "dark")).toBe("t3-chat-dark");
+ });
+
+ it("hydrates text variables and clears the native appearance override", () => {
+ const operations = createMobileThemeRuntimeOperations(null, initialState);
+ const variableOperations = operations.filter(
+ (operation) => operation.kind === "update-text-variables",
+ );
+
+ expect(variableOperations).toHaveLength(12);
+ expect(variableOperations.at(-1)?.themeName).toBe("iris-dark");
+ expect(operations.at(-1)).toEqual({
+ kind: "set-appearance-mode",
+ appearance: "light",
+ themeMode: "system",
+ });
+ });
+
+ it("lets system appearance changes flow through the root ScopedTheme only", () => {
+ const operations = createMobileThemeRuntimeOperations(initialState, {
+ ...initialState,
+ themeAppearance: "dark",
+ });
+
+ expect(operations).toEqual([]);
+ });
+
+ it("updates native appearance once when the selected mode changes", () => {
+ const operations = createMobileThemeRuntimeOperations(initialState, {
+ ...initialState,
+ themeAppearance: "dark",
+ themeMode: "dark",
+ });
+
+ expect(operations).toEqual([
+ {
+ kind: "set-appearance-mode",
+ appearance: "dark",
+ themeMode: "dark",
+ },
+ ]);
+ });
+
+ it("updates text variables for every theme without switching palettes", () => {
+ const operations = createMobileThemeRuntimeOperations(initialState, {
+ ...initialState,
+ baseFontSize: 18,
+ });
+
+ expect(operations).toHaveLength(12);
+ expect(operations.every((operation) => operation.kind === "update-text-variables")).toBe(true);
+ expect(operations.at(-1)).toMatchObject({
+ kind: "update-text-variables",
+ themeName: "iris-dark",
+ });
+ });
+
+ it("does no native work when persistence echoes an already-applied state", () => {
+ expect(createMobileThemeRuntimeOperations(initialState, initialState)).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/lib/mobileThemeRuntime.ts b/apps/mobile/src/lib/mobileThemeRuntime.ts
new file mode 100644
index 0000000000..0c30de6bd4
--- /dev/null
+++ b/apps/mobile/src/lib/mobileThemeRuntime.ts
@@ -0,0 +1,75 @@
+import { resolveTextScaleVariables } from "./appearancePreferences";
+import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes";
+import {
+ DEFAULT_MOBILE_THEME_ID,
+ type MobileThemeAppearance,
+ type MobileThemeId,
+ type MobileThemeMode,
+} from "./mobileTheme";
+
+export type MobileUniwindThemeName =
+ | MobileThemeAppearance
+ | `${BuiltInThemeId}-${MobileThemeAppearance}`;
+
+export interface MobileThemeRuntimeState {
+ readonly baseFontSize: number;
+ readonly themeAppearance: MobileThemeAppearance;
+ readonly themeMode: MobileThemeMode;
+}
+
+export type MobileThemeRuntimeOperation =
+ | {
+ readonly kind: "update-text-variables";
+ readonly themeName: "light" | "dark" | MobileUniwindThemeName;
+ readonly variables: Readonly>;
+ }
+ | {
+ readonly kind: "set-appearance-mode";
+ readonly appearance: MobileThemeAppearance;
+ readonly themeMode: MobileThemeMode;
+ };
+
+const UNIWIND_THEME_NAMES: ReadonlyArray<"light" | "dark" | MobileUniwindThemeName> = [
+ "light",
+ "dark",
+ ...BUILT_IN_THEME_IDS.flatMap((themeId) => [
+ `${themeId}-light` as const,
+ `${themeId}-dark` as const,
+ ]),
+];
+
+export function getMobileUniwindThemeName(
+ themeId: MobileThemeId,
+ appearance: MobileThemeAppearance,
+): MobileUniwindThemeName {
+ return themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`;
+}
+
+/**
+ * Plans imperative runtime work separately from theme selection. Palette
+ * changes are handled by one root ScopedTheme render; only typography and the
+ * native appearance override need imperative Uniwind/React Native updates.
+ */
+export function createMobileThemeRuntimeOperations(
+ previous: MobileThemeRuntimeState | null,
+ next: MobileThemeRuntimeState,
+): ReadonlyArray {
+ const operations: MobileThemeRuntimeOperation[] = [];
+
+ if (previous === null || previous.baseFontSize !== next.baseFontSize) {
+ const variables = resolveTextScaleVariables(next.baseFontSize);
+ for (const themeName of UNIWIND_THEME_NAMES) {
+ operations.push({ kind: "update-text-variables", themeName, variables });
+ }
+ }
+
+ if (previous === null || previous.themeMode !== next.themeMode) {
+ operations.push({
+ kind: "set-appearance-mode",
+ appearance: next.themeAppearance,
+ themeMode: next.themeMode,
+ });
+ }
+
+ return operations;
+}
diff --git a/apps/mobile/src/lib/mobileThemeVariables.test.ts b/apps/mobile/src/lib/mobileThemeVariables.test.ts
new file mode 100644
index 0000000000..78c002b2d5
--- /dev/null
+++ b/apps/mobile/src/lib/mobileThemeVariables.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";
+import { getMobileThemeVariables } from "./mobileTheme";
+import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
+
+describe("mobile theme runtime variables", () => {
+ it("derives the standard runtime palette from global.css", () => {
+ expect(getMobileThemeRuntimeVariables("t3-code", "light")).toEqual(
+ readDefaultMobileThemeVariables("light"),
+ );
+ expect(getMobileThemeRuntimeVariables("t3-code", "dark")).toEqual(
+ readDefaultMobileThemeVariables("dark"),
+ );
+ });
+
+ it("uses the same shared palette source as generated custom themes", () => {
+ expect(getMobileThemeRuntimeVariables("ocean", "light")).toEqual(
+ getMobileThemeVariables("ocean", "light"),
+ );
+ expect(getMobileThemeRuntimeVariables("iris", "dark")).toEqual(
+ getMobileThemeVariables("iris", "dark"),
+ );
+ });
+});
diff --git a/apps/mobile/src/lib/mobileThemeVariables.ts b/apps/mobile/src/lib/mobileThemeVariables.ts
new file mode 100644
index 0000000000..79a479b4fe
--- /dev/null
+++ b/apps/mobile/src/lib/mobileThemeVariables.ts
@@ -0,0 +1,27 @@
+import defaultThemeVariables from "../../generated-uniwind-default-theme-variables.json";
+
+import {
+ DEFAULT_MOBILE_THEME_ID,
+ getMobileThemeVariables,
+ type MobileThemeAppearance,
+ type MobileThemeId,
+ type MobileThemeVariables,
+} from "./mobileTheme";
+
+const defaults = defaultThemeVariables as Readonly<
+ Record
+>;
+
+/**
+ * Complete palette for native and third-party APIs that cannot consume a
+ * Uniwind className. The standard palette is generated from global.css; custom
+ * palettes share the same source that generates their registered CSS themes.
+ */
+export function getMobileThemeRuntimeVariables(
+ themeId: MobileThemeId,
+ appearance: MobileThemeAppearance,
+): MobileThemeVariables {
+ return themeId === DEFAULT_MOBILE_THEME_ID
+ ? defaults[appearance]
+ : getMobileThemeVariables(themeId, appearance);
+}
diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts
index 8a9dabbe03..aafc49e360 100644
--- a/apps/mobile/src/lib/modelOptions.test.ts
+++ b/apps/mobile/src/lib/modelOptions.test.ts
@@ -1,12 +1,14 @@
import { describe, expect, it } from "vite-plus/test";
-import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
+import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3tools/contracts";
import {
buildModelOptions,
groupByProvider,
resolveDefaultableModelSelection,
+ resolveNewTaskModelSelection,
resolveSelectableModelSelection,
+ type ModelOption,
} from "./modelOptions";
describe("mobile model options", () => {
@@ -44,13 +46,62 @@ describe("mobile model options", () => {
providerKey: "codex",
providerLabel: "Codex",
models: [
- { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", isLegacy: false },
+ { key: "codex:gpt-5.6-sol", label: "GPT-5.6 Sol", subtitle: "", isLegacy: false },
{ key: "codex:gpt-5.4", label: "GPT-5.4", isLegacy: true },
],
},
]);
});
+ it("distinguishes same-name OpenCode models without changing their routing", () => {
+ const sources = [
+ { id: "anthropic", label: "Anthropic" },
+ { id: "github-copilot", label: "GitHub Copilot" },
+ { id: "opencode", label: "OpenCode Zen" },
+ ];
+ const config = {
+ providers: [
+ {
+ instanceId: "opencode_work",
+ driver: "opencode",
+ displayName: "OpenCode Work",
+ enabled: true,
+ installed: true,
+ auth: { status: "authenticated" },
+ models: sources.map((source) => ({
+ slug: `${source.id}/claude-fable-5`,
+ name: "Claude Fable 5",
+ subProvider: source.label,
+ isCustom: false,
+ capabilities: null,
+ })),
+ },
+ ],
+ } as unknown as ServerConfig;
+ const selection = {
+ instanceId: ProviderInstanceId.make("opencode_work"),
+ model: "github-copilot/claude-fable-5",
+ };
+
+ const options = buildModelOptions(config, selection);
+
+ expect(options).toMatchObject(
+ sources.map((source) => ({
+ key: `opencode_work:${source.id}/claude-fable-5`,
+ label: "Claude Fable 5",
+ subtitle: source.label,
+ providerLabel: "OpenCode Work",
+ selection: {
+ instanceId: "opencode_work",
+ model: `${source.id}/claude-fable-5`,
+ },
+ })),
+ );
+ expect(groupByProvider(options)).toEqual([
+ { providerKey: "opencode_work", providerLabel: "OpenCode Work", models: options },
+ ]);
+ });
+
it("normalizes a legacy fallback selection against current capabilities", () => {
const config = {
providers: [
@@ -171,4 +222,30 @@ describe("mobile model options", () => {
// Offline: nothing to validate against, selection passes through.
expect(resolveDefaultableModelSelection(null, legacy)).toBe(legacy);
});
+
+ it("resolves new tasks from draft, project, sticky, then provider defaults", () => {
+ const draft = { instanceId: ProviderInstanceId.make("codex"), model: "draft" };
+ const project = { instanceId: ProviderInstanceId.make("codex"), model: "project" };
+ const sticky = { instanceId: ProviderInstanceId.make("codex"), model: "sticky" };
+ const providerDefault = {
+ selection: { instanceId: ProviderInstanceId.make("codex"), model: "default" },
+ isDefault: true,
+ } as ModelOption;
+ const resolve = (
+ draftSelection: ModelSelection | null,
+ projectDefaultSelection: ModelSelection | null,
+ stickySelection: ModelSelection | null,
+ ) =>
+ resolveNewTaskModelSelection({
+ draftSelection,
+ projectDefaultSelection,
+ stickySelection,
+ modelOptions: [providerDefault],
+ });
+
+ expect(resolve(draft, project, sticky)).toBe(draft);
+ expect(resolve(null, project, sticky)).toBe(project);
+ expect(resolve(null, null, sticky)).toBe(sticky);
+ expect(resolve(null, null, null)).toBe(providerDefault.selection);
+ });
});
diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts
index cb7a8c4198..26ffd68555 100644
--- a/apps/mobile/src/lib/modelOptions.ts
+++ b/apps/mobile/src/lib/modelOptions.ts
@@ -104,6 +104,22 @@ export function resolveDefaultableModelSelection(
return model?.isLegacy === true ? null : usable;
}
+export function resolveNewTaskModelSelection(input: {
+ readonly draftSelection: ModelSelection | null;
+ readonly projectDefaultSelection: ModelSelection | null;
+ readonly stickySelection: ModelSelection | null;
+ readonly modelOptions: ReadonlyArray;
+}): ModelSelection | null {
+ return (
+ input.draftSelection ??
+ input.projectDefaultSelection ??
+ input.stickySelection ??
+ input.modelOptions.find((option) => option.isDefault)?.selection ??
+ input.modelOptions[0]?.selection ??
+ null
+ );
+}
+
export function buildModelOptions(
config: T3ServerConfig | null | undefined,
fallbackModelSelection: ModelSelection | null,
@@ -121,7 +137,7 @@ export function buildModelOptions(
options.set(key, {
key,
label: model.name,
- subtitle: providerLabel,
+ subtitle: model.subProvider ?? "",
providerKey: provider.instanceId,
providerLabel,
providerDriver: provider.driver,
@@ -152,7 +168,7 @@ export function buildModelOptions(
options.set(key, {
key,
label: fallbackModelSelection.model,
- subtitle: providerLabel,
+ subtitle: "",
providerKey: fallbackModelSelection.instanceId,
providerLabel,
providerDriver: fallbackModelSelection.instanceId,
diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts
index fe022c1191..a1c7960a57 100644
--- a/apps/mobile/src/lib/storage.test.ts
+++ b/apps/mobile/src/lib/storage.test.ts
@@ -196,6 +196,12 @@ describe("mobile connection storage", () => {
});
});
+ it("drops the removed theme transition preference", async () => {
+ mocks.setPreferencesJson(JSON.stringify({ themeTransition: "circle-bottom-left" }), 10);
+
+ await expect(loadPreferences()).resolves.toEqual({});
+ });
+
it("falls back to secure storage when SQLite cannot save preferences", async () => {
mocks.setDatabaseFailures(true, true);
await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
index a775988297..e1ed10f26f 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -325,6 +325,38 @@ describe("buildThreadFeed", () => {
]);
});
+ it("drops runtime warnings with no displayable content", () => {
+ const thread = makeThread({
+ id: ThreadId.make("thread-noise"),
+ projectId: ProjectId.make("project-1"),
+ title: "Warning noise thread",
+ activities: [
+ makeActivity({
+ id: EventId.make("activity-noise"),
+ kind: "runtime.warning",
+ summary: "Claude system message 'background_tasks_changed' (no displayable text content)",
+ createdAt: "2026-04-01T00:00:02.000Z",
+ turnId: TurnId.make("turn-1"),
+ }),
+ makeActivity({
+ id: EventId.make("activity-signal"),
+ kind: "runtime.warning",
+ summary: "Reconnecting... 2/5",
+ createdAt: "2026-04-01T00:00:03.000Z",
+ turnId: TurnId.make("turn-1"),
+ }),
+ ],
+ });
+
+ const feed = buildThreadFeed(thread);
+ expect(feed).toMatchObject([
+ {
+ type: "activity-group",
+ activities: [{ id: "activity-signal" }],
+ },
+ ]);
+ });
+
it("collapses matching tool lifecycle rows like desktop", () => {
const thread = makeThread({
id: ThreadId.make("thread-2"),
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 9e0cb64ae8..6dd8230307 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -338,6 +338,7 @@ function deriveWorkLogEntries(
if (activity.kind === "tool.progress") continue;
if (activity.kind === "context-window.updated") continue;
if (activity.summary === "Checkpoint captured") continue;
+ if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
if (isAgentInternalActivity(activity)) continue;
entries.push(toDerivedWorkLogEntry(activity));
@@ -345,6 +346,17 @@ function deriveWorkLogEntries(
return collapseDerivedWorkLogEntries(entries);
}
+/** Adapters forward unknown wire-only SDK messages (background_tasks_changed,
+ * commands_changed, ...) as runtime warnings. The suffix comes from
+ * describeUnknownSdkMessage in the Claude adapter; a row with no displayable
+ * text carries nothing a user can act on, so it does not render. */
+function isNoContentRuntimeWarning(activity: OrchestrationThreadActivity): boolean {
+ return (
+ activity.kind === "runtime.warning" &&
+ activity.summary.endsWith("(no displayable text content)")
+ );
+}
+
function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
return false;
diff --git a/apps/mobile/src/lib/useFontFamily.ts b/apps/mobile/src/lib/useFontFamily.ts
index 09805ae115..4f845753be 100644
--- a/apps/mobile/src/lib/useFontFamily.ts
+++ b/apps/mobile/src/lib/useFontFamily.ts
@@ -1,15 +1,13 @@
-import { useCSSVariable } from "uniwind";
-
-const FONT_FAMILY_VARIABLES = {
- regular: "--font-sans",
- medium: "--font-medium",
- bold: "--font-bold",
+const FONT_FAMILIES = {
+ regular: "DMSans-Regular",
+ medium: "DMSans-Medium",
+ bold: "DMSans-Bold",
} as const;
/**
* Resolves a font family for APIs that require a style object or native prop.
* Prefer Uniwind font classes when the target component accepts `className`.
*/
-export function useFontFamily(weight: keyof typeof FONT_FAMILY_VARIABLES): string {
- return useCSSVariable(FONT_FAMILY_VARIABLES[weight]) as string;
+export function useFontFamily(weight: keyof typeof FONT_FAMILIES): string {
+ return FONT_FAMILIES[weight];
}
diff --git a/apps/mobile/src/lib/useMobileNavigationTheme.ts b/apps/mobile/src/lib/useMobileNavigationTheme.ts
index 6711f72c74..7b7a1fdf3c 100644
--- a/apps/mobile/src/lib/useMobileNavigationTheme.ts
+++ b/apps/mobile/src/lib/useMobileNavigationTheme.ts
@@ -1,22 +1,31 @@
import { DarkTheme, DefaultTheme, type Theme } from "@react-navigation/native";
import { useMemo } from "react";
-import type { MobileThemeAppearance } from "./mobileTheme";
-import { useThemeColor } from "./useThemeColor";
-
-export function useMobileNavigationTheme(appearance: MobileThemeAppearance): Theme {
- const primary = String(useThemeColor("--color-primary"));
- const background = String(useThemeColor("--color-screen"));
- const card = String(useThemeColor("--color-sheet-solid"));
- const text = String(useThemeColor("--color-foreground"));
- const border = String(useThemeColor("--color-header-border"));
- const notification = String(useThemeColor("--color-danger-foreground"));
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import { useUniwindTheme } from "./useUniwindTheme";
+/**
+ * React Navigation requires a JS theme object. Derive it from the same palette
+ * source as Uniwind instead of subscribing the app root to CSS variables. The
+ * preferences provider applies the registered Uniwind theme first, then
+ * publishes this matching navigation palette through React.
+ */
+export function useMobileNavigationTheme(): Theme {
+ const { themeAppearance: appearance } = useAppearancePreferences();
+ const variables = useUniwindTheme();
return useMemo(() => {
const base = appearance === "dark" ? DarkTheme : DefaultTheme;
return {
...base,
- colors: { ...base.colors, primary, background, card, text, border, notification },
+ colors: {
+ ...base.colors,
+ primary: variables["--color-primary"],
+ background: variables["--color-screen"],
+ card: variables["--color-sheet-solid"],
+ text: variables["--color-foreground"],
+ border: variables["--color-header-border"],
+ notification: variables["--color-danger-foreground"],
+ },
};
- }, [appearance, background, border, card, notification, primary, text]);
+ }, [appearance, variables]);
}
diff --git a/apps/mobile/src/lib/useThemeColor.ts b/apps/mobile/src/lib/useThemeColor.ts
deleted file mode 100644
index 38dbf6c9b0..0000000000
--- a/apps/mobile/src/lib/useThemeColor.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import type { ColorValue } from "react-native";
-import { useCSSVariable } from "uniwind";
-
-/**
- * Typed wrapper around `useCSSVariable` that returns a `ColorValue` for use
- * in React Native style props (backgroundColor, tintColor, etc.).
- *
- * Usage: `const color = useThemeColor("--color-icon");`
- */
-export function useThemeColor(variable: `--color-${string}`): ColorValue {
- return useCSSVariable(variable) as string as ColorValue;
-}
diff --git a/apps/mobile/src/lib/useUniwindTheme.ts b/apps/mobile/src/lib/useUniwindTheme.ts
new file mode 100644
index 0000000000..06c50c859a
--- /dev/null
+++ b/apps/mobile/src/lib/useUniwindTheme.ts
@@ -0,0 +1,21 @@
+import { useMemo } from "react";
+
+import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
+import type { MobileThemeVariables } from "./mobileTheme";
+import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
+
+/**
+ * Complete JS palette for native and third-party APIs that cannot consume a
+ * Uniwind className (React Navigation, native editors, Markdown, SVG gradients,
+ * Reanimated worklets). Ordinary React Native rendering must use className.
+ *
+ * This bridge follows the same single React theme commit as the root
+ * ScopedTheme instead of subscribing every consumer to CSS-variable updates.
+ */
+export function useUniwindTheme(): MobileThemeVariables {
+ const { themeAppearance, themeId } = useAppearancePreferences();
+ return useMemo(
+ () => getMobileThemeRuntimeVariables(themeId, themeAppearance),
+ [themeAppearance, themeId],
+ );
+}
diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
index 32094109b1..cb818245d6 100644
--- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx
@@ -14,7 +14,7 @@ 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 { useUniwindTheme } from "../lib/useUniwindTheme";
import { useFontFamily } from "../lib/useFontFamily";
import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
import {
@@ -110,15 +110,7 @@ export function ComposerEditor({
const nativeEventSnapshotsRef = useRef([]);
const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
const bodyText = useScaledTextRole("body");
- 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");
+ const theme = useUniwindTheme();
const fontFamily = useFontFamily("regular");
useImperativeHandle(
@@ -219,15 +211,15 @@ export function ComposerEditor({
[],
);
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),
+ text: theme["--color-foreground"],
+ placeholder: theme["--color-placeholder"],
+ chipBackground: theme["--color-subtle"],
+ chipBorder: theme["--color-border"],
+ chipText: theme["--color-foreground"],
+ skillBackground: theme["--color-inline-skill-background"],
+ skillBorder: theme["--color-inline-skill-border"],
+ skillText: theme["--color-inline-skill-foreground"],
+ fileTint: theme["--color-icon-muted"],
});
const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {};
return (
diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx
index ff177abf16..5f5201f3bf 100644
--- a/apps/mobile/src/native/T3ComposerEditor.native.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx
@@ -18,7 +18,7 @@ import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links";
import { MOBILE_TYPOGRAPHY } from "../lib/typography";
import { useNativePaste } from "../lib/useNativePaste";
import { useFontFamily } from "../lib/useFontFamily";
-import { useThemeColor } from "../lib/useThemeColor";
+import { useUniwindTheme } from "../lib/useUniwindTheme";
import {
acknowledgeComposerNativeEvent,
assumeComposerControlledState,
@@ -111,15 +111,7 @@ export function ComposerEditor({
const nativeEventSnapshotsRef = useRef([]);
const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
const confirmedTokensRef = useRef(initialConfirmedTokens);
- 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");
+ const theme = useUniwindTheme();
const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
useImperativeHandle(
@@ -220,15 +212,15 @@ export function ComposerEditor({
[],
);
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),
+ text: theme["--color-foreground"],
+ placeholder: theme["--color-placeholder"],
+ chipBackground: theme["--color-subtle"],
+ chipBorder: theme["--color-border"],
+ chipText: theme["--color-foreground"],
+ skillBackground: theme["--color-inline-skill-background"],
+ skillBorder: theme["--color-inline-skill-border"],
+ skillText: theme["--color-inline-skill-foreground"],
+ fileTint: theme["--color-icon-muted"],
});
const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {};
const regularFontFamily = useFontFamily("regular");
diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx
index e082d3892a..30bc0cceea 100644
--- a/apps/mobile/src/native/T3ComposerEditor.tsx
+++ b/apps/mobile/src/native/T3ComposerEditor.tsx
@@ -2,7 +2,6 @@ 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 { useFontFamily } from "../lib/useFontFamily";
import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
import { useNativePaste } from "../lib/useNativePaste";
@@ -21,8 +20,6 @@ export function ComposerEditor({
}: ComposerEditorProps) {
const inputRef = useRef(null);
const bodyText = useScaledTextRole("body");
- const foregroundColor = useThemeColor("--color-foreground");
- const placeholderColor = useThemeColor("--color-placeholder");
const fontFamily = useFontFamily("regular");
const handlePaste = useNativePaste((uris) => onPasteImages?.(uris));
@@ -45,12 +42,12 @@ export function ComposerEditor({
selection={selection}
onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)}
multiline={props.multiline ?? true}
- placeholderTextColor={placeholderColor}
+ placeholderTextColorClassName={"accent-placeholder"}
+ className="text-foreground"
style={[
{
flex: 1,
minHeight: 0,
- color: foregroundColor,
fontFamily,
...bodyText,
paddingVertical: contentInsetVertical,
diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts
index 76d57d5579..ab8e5a20f0 100644
--- a/apps/mobile/src/state/thread-pr-presentation.ts
+++ b/apps/mobile/src/state/thread-pr-presentation.ts
@@ -17,9 +17,9 @@ export interface ThreadPrPresentation {
}
const PR_STATE_TEXT_CLASS: Record = {
- open: "text-emerald-600 dark:text-emerald-400",
- merged: "text-violet-600 dark:text-violet-400",
- closed: "text-zinc-500 dark:text-zinc-400",
+ open: "text-adaptive-emerald-600-400",
+ merged: "text-adaptive-violet-600-400",
+ closed: "text-adaptive-zinc-500-400",
};
export function presentThreadPr(
diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts
index 8dbddfe1fe..5713090543 100644
--- a/apps/mobile/src/state/use-composer-drafts.test.ts
+++ b/apps/mobile/src/state/use-composer-drafts.test.ts
@@ -7,6 +7,9 @@ const composerDraftFileMocks = vi.hoisted(() => {
let writeError: Error | null = null;
let releaseRead: (() => void) | null = null;
let readBarrier = Promise.resolve();
+ let nextWriteBarrier: Promise | null = null;
+ let onWrite: (() => void) | null = null;
+ const writes: string[] = [];
return {
blockRead() {
@@ -27,6 +30,18 @@ const composerDraftFileMocks = vi.hoisted(() => {
setWriteError(error: Error | null) {
writeError = error;
},
+ setNextWriteBarrier(barrier: Promise | null) {
+ nextWriteBarrier = barrier;
+ },
+ setOnWrite(callback: (() => void) | null) {
+ onWrite = callback;
+ },
+ getWrites(): ReadonlyArray {
+ return writes;
+ },
+ resetWrites() {
+ writes.length = 0;
+ },
Directory: class {
create() {}
},
@@ -47,7 +62,18 @@ const composerDraftFileMocks = vi.hoisted(() => {
if (writeError) {
throw writeError;
}
+ if (nextWriteBarrier) {
+ const barrier = nextWriteBarrier;
+ nextWriteBarrier = null;
+ return barrier.then(() => {
+ document = value;
+ writes.push(value);
+ onWrite?.();
+ });
+ }
document = value;
+ writes.push(value);
+ onWrite?.();
}
},
};
@@ -62,18 +88,24 @@ vi.mock("expo-file-system", () => ({
import { appAtomRegistry } from "./atom-registry";
import {
clearComposerDraftContentState,
+ clearComposerDraftsEnvironment,
ComposerDraftPersistenceError,
composerDraftsAtom,
copyComposerDraftContentIfEmpty,
copyComposerDraftContentState,
+ decodePersistedComposerState,
decodePersistedComposerDrafts,
+ ensureComposerDraftsLoaded,
type ComposerDraft,
flushComposerDrafts,
getComposerDraftSnapshot,
mergeComposerDraftContentState,
removeComposerDraftsForEnvironment,
+ resetComposerDraftsLoadState,
restoreComposerDraftSnapshotState,
setComposerDraftText,
+ setStickyComposerModelSelection,
+ stickyComposerModelSelectionAtom,
} from "./use-composer-drafts";
const DRAFT: ComposerDraft = {
@@ -82,7 +114,15 @@ const DRAFT: ComposerDraft = {
};
afterEach(() => {
+ vi.useRealTimers();
+ resetComposerDraftsLoadState();
+ composerDraftFileMocks.setDocument("");
+ composerDraftFileMocks.setWriteError(null);
+ composerDraftFileMocks.setNextWriteBarrier(null);
+ composerDraftFileMocks.setOnWrite(null);
+ composerDraftFileMocks.resetWrites();
appAtomRegistry.set(composerDraftsAtom, {});
+ appAtomRegistry.set(stickyComposerModelSelectionAtom, null);
});
describe("mobile composer drafts", () => {
@@ -154,6 +194,195 @@ describe("mobile composer drafts", () => {
).toThrow();
});
+ it("keeps share-import receipts on otherwise contentless new-task drafts", () => {
+ const receiptDraft: ComposerDraft = {
+ text: "",
+ attachments: [],
+ importedShareIds: ["share-1"],
+ };
+ // The stale-model strip must not touch receipt-bearing drafts, and the
+ // empty filter must keep them — or the same share would re-import after
+ // restart.
+ expect(
+ decodePersistedComposerState({
+ schemaVersion: 1,
+ drafts: {
+ "new-task:environment-1:project-1": {
+ ...receiptDraft,
+ modelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.4",
+ },
+ },
+ },
+ }).drafts,
+ ).toEqual({
+ "new-task:environment-1:project-1": {
+ text: "",
+ attachments: [],
+ importedShareIds: ["share-1"],
+ },
+ });
+
+ expect(
+ decodePersistedComposerState({
+ schemaVersion: 1,
+ drafts: { "new-task:environment-1:project-1": receiptDraft },
+ }).drafts,
+ ).toEqual({ "new-task:environment-1:project-1": receiptDraft });
+ });
+
+ it("hydrates the global sticky model selection", () => {
+ expect(
+ decodePersistedComposerState({
+ schemaVersion: 1,
+ drafts: {},
+ stickyModelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ },
+ }).stickyModelSelection,
+ ).toEqual({
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ });
+ });
+
+ it("waits for hydration before persisting the latest composer state", async () => {
+ vi.useFakeTimers();
+ composerDraftFileMocks.setDocument({
+ schemaVersion: 1,
+ drafts: {
+ "environment-1:thread-1": DRAFT,
+ },
+ stickyModelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ },
+ });
+ composerDraftFileMocks.blockRead();
+ composerDraftFileMocks.resetWrites();
+
+ ensureComposerDraftsLoaded();
+ await Promise.resolve();
+ // The read is blocked, hydration is pending.
+ setComposerDraftText("new-task:environment-1:project-1", "New prompt");
+ await vi.advanceTimersByTimeAsync(200);
+
+ // Write should still be deferred — hydration has not resolved.
+ expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
+
+ composerDraftFileMocks.releaseRead();
+ // Let the loadPromise settle and chain into the deferred persist.
+ await vi.runAllTimersAsync();
+
+ expect(JSON.parse(composerDraftFileMocks.getWrites()[0]!)).toEqual({
+ schemaVersion: 1,
+ drafts: {
+ "environment-1:thread-1": DRAFT,
+ "new-task:environment-1:project-1": {
+ text: "New prompt",
+ attachments: [],
+ },
+ },
+ stickyModelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ },
+ });
+ });
+
+ it("flush waits for pending hydration instead of clobbering disk", async () => {
+ vi.useFakeTimers();
+ composerDraftFileMocks.setDocument({
+ schemaVersion: 1,
+ drafts: {
+ "environment-1:thread-1": DRAFT,
+ },
+ stickyModelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ },
+ });
+ composerDraftFileMocks.blockRead();
+ composerDraftFileMocks.resetWrites();
+
+ ensureComposerDraftsLoaded();
+ await Promise.resolve();
+ // An edit lands before hydration finishes; its debounced write is gated
+ // behind the blocked read.
+ setComposerDraftText("new-task:environment-1:project-1", "New prompt");
+
+ const flush = flushComposerDrafts();
+ await vi.advanceTimersByTimeAsync(200);
+ // The flush must not have written the pre-hydration snapshot over disk.
+ expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
+
+ composerDraftFileMocks.releaseRead();
+ await flush;
+
+ const written = JSON.parse(composerDraftFileMocks.getDocument());
+ expect(written.drafts["environment-1:thread-1"]).toEqual(DRAFT);
+ expect(written.drafts["new-task:environment-1:project-1"]).toEqual({
+ text: "New prompt",
+ attachments: [],
+ });
+ expect(written.stickyModelSelection).toEqual({
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ });
+ });
+
+ it("serializes environment cleanup after an older queued write", async () => {
+ vi.useFakeTimers();
+ composerDraftFileMocks.setDocument(JSON.stringify({ schemaVersion: 1, drafts: {} }));
+ composerDraftFileMocks.resetWrites();
+ let releaseFirstWrite!: () => void;
+ const firstWriteBarrier = new Promise((resolve) => {
+ releaseFirstWrite = resolve;
+ });
+ composerDraftFileMocks.setNextWriteBarrier(firstWriteBarrier);
+ let writeCount = 0;
+ const bothWritesCommitted = new Promise((resolve) => {
+ composerDraftFileMocks.setOnWrite(() => {
+ writeCount += 1;
+ if (writeCount === 2) {
+ resolve();
+ }
+ });
+ });
+
+ appAtomRegistry.set(composerDraftsAtom, {
+ "environment-1:thread-1": DRAFT,
+ "environment-2:thread-2": { text: "keep", attachments: [] },
+ });
+ setStickyComposerModelSelection({
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5.6-sol",
+ });
+ await vi.advanceTimersByTimeAsync(200);
+
+ const clear = clearComposerDraftsEnvironment(EnvironmentId.make("environment-1"));
+ await Promise.resolve();
+ // Cleanup write is queued behind the still-blocked debounced write.
+ expect(composerDraftFileMocks.getWrites()).toHaveLength(0);
+
+ releaseFirstWrite();
+ await clear;
+ await bothWritesCommitted;
+
+ expect(JSON.parse(composerDraftFileMocks.getDocument())).toEqual({
+ schemaVersion: 1,
+ drafts: {
+ "environment-2:thread-2": { text: "keep", attachments: [] },
+ },
+ stickyModelSelection: {
+ instanceId: "codex",
+ model: "gpt-5.6-sol",
+ },
+ });
+ });
+
it("clears sent content without clearing the selected model or workspace", () => {
const draftKey = "environment-1:thread-1";
const draft: ComposerDraft = {
@@ -182,7 +411,7 @@ describe("mobile composer drafts", () => {
});
});
- it("drops the workspace selection when clearing a sent new-task draft", () => {
+ it("drops draft-local model and workspace selections after sending a new task", () => {
const draftKey = "new-task:environment-1:project-1";
const draft: ComposerDraft = {
text: "send this",
@@ -201,15 +430,10 @@ describe("mobile composer drafts", () => {
expect(
clearComposerDraftContentState({ [draftKey]: draft }, draftKey, {
+ clearModelSelection: true,
clearWorkspaceSelection: true,
}),
- ).toEqual({
- [draftKey]: {
- modelSelection: draft.modelSelection,
- text: "",
- attachments: [],
- },
- });
+ ).toEqual({});
});
it("reads the latest selector state synchronously for send", () => {
diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts
index 7dbea23596..1cc3ef4e93 100644
--- a/apps/mobile/src/state/use-composer-drafts.ts
+++ b/apps/mobile/src/state/use-composer-drafts.ts
@@ -86,6 +86,7 @@ const ComposerDraftSchema = Schema.Struct({
const PersistedComposerDraftsSchema = Schema.Struct({
schemaVersion: Schema.Literal(COMPOSER_DRAFTS_SCHEMA_VERSION),
drafts: Schema.Record(Schema.String, ComposerDraftSchema),
+ stickyModelSelection: Schema.optional(ModelSelectionSchema),
});
const decodePersistedComposerDraftsDocument = Schema.decodeUnknownSync(
@@ -102,10 +103,20 @@ export const composerDraftsAtom = Atom.make>({}).p
Atom.withLabel("mobile:composer-drafts"),
);
+export const stickyComposerModelSelectionAtom = Atom.make(null).pipe(
+ Atom.keepAlive,
+ Atom.withLabel("mobile:sticky-composer-model-selection"),
+);
+
let loadPromise: Promise | null = null;
let persistTimer: ReturnType | null = null;
const persistenceQueue = new SerializedAsyncQueue();
+/** Resets module-level state between test runs. */
+export function resetComposerDraftsLoadState(): void {
+ loadPromise = null;
+}
+
function normalizeDraft(draft: ComposerDraft | undefined): ComposerDraft {
if (!draft) {
return EMPTY_DRAFT;
@@ -136,11 +147,46 @@ function isEmptyDraft(draft: ComposerDraft): boolean {
);
}
-export function decodePersistedComposerDrafts(value: unknown): Record {
+export function decodePersistedComposerState(value: unknown): {
+ readonly drafts: Record;
+ readonly stickyModelSelection: ModelSelection | null;
+} {
const parsed = decodePersistedComposerDraftsDocument(value);
- return Object.fromEntries(
- Object.entries(parsed.drafts).filter(([, draft]) => !isEmptyDraft(draft)),
- );
+ return {
+ drafts: Object.fromEntries(
+ Object.entries(parsed.drafts)
+ .map(
+ ([key, draft]) =>
+ [
+ key,
+ // Stale new-task drafts left on disk by builds before the
+ // model-precedence fix carry a bare modelSelection with no
+ // other selector settings. Strip it so the next compose pass
+ // re-resolves project → sticky → provider defaults. Drafts
+ // with runtime/interaction/workspace settings or actual text /
+ // attachments were deliberately configured and are left alone.
+ key.startsWith("new-task:") &&
+ draft.modelSelection &&
+ draft.text.length === 0 &&
+ draft.attachments.length === 0 &&
+ draft.runtimeMode === undefined &&
+ draft.interactionMode === undefined &&
+ draft.workspaceSelection === undefined
+ ? { ...draft, modelSelection: undefined }
+ : draft,
+ ] as const,
+ )
+ // importedShareIds are share-import receipts: a contentless draft
+ // carrying one is not empty, or the same native share would be
+ // re-imported after restart.
+ .filter(([, draft]) => !isEmptyDraft(draft) || (draft.importedShareIds?.length ?? 0) > 0),
+ ),
+ stickyModelSelection: parsed.stickyModelSelection ?? null,
+ };
+}
+
+export function decodePersistedComposerDrafts(value: unknown): Record {
+ return decodePersistedComposerState(value).drafts;
}
async function getComposerDraftsFile() {
@@ -150,17 +196,20 @@ async function getComposerDraftsFile() {
return new File(directory, COMPOSER_DRAFTS_FILE);
}
-async function loadPersistedComposerDrafts(): Promise> {
+async function loadPersistedComposerState(): Promise<{
+ readonly drafts: Record;
+ readonly stickyModelSelection: ModelSelection | null;
+}> {
let operation: ComposerDraftPersistenceError["operation"] = "open";
try {
const file = await getComposerDraftsFile();
if (!file.exists) {
- return {};
+ return { drafts: {}, stickyModelSelection: null };
}
operation = "read";
const raw = await file.text();
operation = "decode";
- return decodePersistedComposerDrafts(JSON.parse(raw) as unknown);
+ return decodePersistedComposerState(JSON.parse(raw) as unknown);
} catch (cause) {
console.warn(
"[composer-drafts] ignored persisted draft failure",
@@ -171,11 +220,14 @@ async function loadPersistedComposerDrafts(): Promise): Promise {
+async function writePersistedComposerState(
+ drafts: Record,
+ stickyModelSelection: ModelSelection | null,
+): Promise {
let operation: ComposerDraftPersistenceError["operation"] = "open";
try {
const file = await getComposerDraftsFile();
@@ -186,6 +238,7 @@ async function writePersistedComposerDrafts(drafts: Record): Promise {
- try {
- await persistenceQueue.run(() => writePersistedComposerDrafts(drafts));
- } catch (error) {
- console.warn("[composer-drafts] failed to persist drafts", error);
- // Draft persistence is best-effort; in-memory drafts still keep working.
- }
-}
-
/**
* Lands any debounced or in-flight draft write before the JS runtime is torn
* down (app update restart), so the freshest draft state survives it. A write
* failure propagates so the caller can decide whether the restart may proceed.
*/
export async function flushComposerDrafts(): Promise {
+ // Never land a pre-hydration snapshot: persisted state must merge into the
+ // atoms first, or this write would clobber disk with partial data.
+ ensureComposerDraftsLoaded();
+ if (loadPromise !== null) {
+ await loadPromise;
+ }
// An edit during an awaited write schedules another debounced write, so
// keep landing snapshots until no debounce is pending after a queue drain.
do {
@@ -222,20 +272,41 @@ export async function flushComposerDrafts(): Promise {
clearTimeout(persistTimer);
persistTimer = null;
await persistenceQueue.run(() =>
- writePersistedComposerDrafts(appAtomRegistry.get(composerDraftsAtom)),
+ writePersistedComposerState(
+ appAtomRegistry.get(composerDraftsAtom),
+ appAtomRegistry.get(stickyComposerModelSelectionAtom),
+ ),
);
}
+ // Draining also waits for an already-fired debounce whose write is still
+ // gated behind its own hydration await inside the queue.
await persistenceQueue.run(() => Promise.resolve());
} while (persistTimer !== null);
}
-function schedulePersistComposerDrafts(drafts: Record): void {
+function schedulePersistComposerState(): void {
if (persistTimer !== null) {
clearTimeout(persistTimer);
}
persistTimer = setTimeout(() => {
persistTimer = null;
- void savePersistedComposerDrafts(drafts);
+ ensureComposerDraftsLoaded();
+ // The write enters the serialization queue before waiting on hydration,
+ // so flushComposerDrafts' queue drain cannot resolve ahead of it.
+ void persistenceQueue.run(async () => {
+ if (loadPromise !== null) {
+ await loadPromise;
+ }
+ try {
+ await writePersistedComposerState(
+ appAtomRegistry.get(composerDraftsAtom),
+ appAtomRegistry.get(stickyComposerModelSelectionAtom),
+ );
+ } catch (error) {
+ console.warn("[composer-drafts] failed to persist drafts", error);
+ // Draft persistence is best-effort; in-memory drafts still keep working.
+ }
+ });
}, PERSIST_DEBOUNCE_MS);
}
@@ -243,16 +314,21 @@ export function ensureComposerDraftsLoaded(): void {
if (loadPromise !== null) {
return;
}
- loadPromise = loadPersistedComposerDrafts()
- .then((persistedDrafts) => {
- if (Object.keys(persistedDrafts).length === 0) {
- return;
+ loadPromise = loadPersistedComposerState()
+ .then((persisted) => {
+ if (Object.keys(persisted.drafts).length > 0) {
+ const current = appAtomRegistry.get(composerDraftsAtom);
+ appAtomRegistry.set(composerDraftsAtom, {
+ ...persisted.drafts,
+ ...current,
+ });
+ }
+ if (
+ persisted.stickyModelSelection !== null &&
+ appAtomRegistry.get(stickyComposerModelSelectionAtom) === null
+ ) {
+ appAtomRegistry.set(stickyComposerModelSelectionAtom, persisted.stickyModelSelection);
}
- const current = appAtomRegistry.get(composerDraftsAtom);
- appAtomRegistry.set(composerDraftsAtom, {
- ...persistedDrafts,
- ...current,
- });
})
.catch((cause) => {
console.warn(
@@ -277,7 +353,12 @@ function updateComposerDrafts(
return;
}
appAtomRegistry.set(composerDraftsAtom, next);
- schedulePersistComposerDrafts(next);
+ schedulePersistComposerState();
+}
+
+export function setStickyComposerModelSelection(modelSelection: ModelSelection): void {
+ appAtomRegistry.set(stickyComposerModelSelectionAtom, modelSelection);
+ schedulePersistComposerState();
}
export function setComposerDraftText(draftKey: string, value: string): void {
@@ -394,15 +475,24 @@ export function updateComposerDraftSettings(
export function clearComposerDraftContentState(
current: Record,
draftKey: string,
- options?: { readonly clearWorkspaceSelection?: boolean },
+ options?: {
+ readonly clearModelSelection?: boolean;
+ readonly clearWorkspaceSelection?: boolean;
+ },
): Record {
const existing = current[draftKey];
if (!existing) {
return current;
}
- const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing;
+ const {
+ importedShareIds: _importedShareIds,
+ modelSelection,
+ workspaceSelection,
+ ...retained
+ } = existing;
const draft = {
...retained,
+ ...(options?.clearModelSelection || modelSelection === undefined ? {} : { modelSelection }),
...(options?.clearWorkspaceSelection || workspaceSelection === undefined
? {}
: { workspaceSelection }),
@@ -571,7 +661,9 @@ export async function mergeComposerDraftContent(
if (next !== current) {
appAtomRegistry.set(composerDraftsAtom, next);
}
- await persistenceQueue.run(() => writePersistedComposerDrafts(next));
+ await persistenceQueue.run(() =>
+ writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
+ );
return { skippedAttachmentCount };
}
@@ -594,12 +686,17 @@ export async function restoreComposerDraftSnapshot(
snapshot,
);
appAtomRegistry.set(composerDraftsAtom, next);
- await persistenceQueue.run(() => writePersistedComposerDrafts(next));
+ await persistenceQueue.run(() =>
+ writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
+ );
}
export function clearComposerDraftContent(
draftKey: string,
- options?: { readonly clearWorkspaceSelection?: boolean },
+ options?: {
+ readonly clearModelSelection?: boolean;
+ readonly clearWorkspaceSelection?: boolean;
+ },
): void {
updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options));
}
@@ -645,7 +742,9 @@ export async function clearComposerDraftsEnvironment(environmentId: EnvironmentI
persistTimer = null;
}
appAtomRegistry.set(composerDraftsAtom, next);
- await persistenceQueue.run(() => writePersistedComposerDrafts(next));
+ await persistenceQueue.run(() =>
+ writePersistedComposerState(next, appAtomRegistry.get(stickyComposerModelSelectionAtom)),
+ );
}
export function useComposerDraft(draftKey: string | null): ComposerDraft {
@@ -655,3 +754,11 @@ export function useComposerDraft(draftKey: string | null): ComposerDraft {
}, []);
return draftKey ? normalizeDraft(drafts[draftKey]) : EMPTY_DRAFT;
}
+
+export function useStickyComposerModelSelection(): ModelSelection | null {
+ const selection = useAtomValue(stickyComposerModelSelectionAtom);
+ useEffect(() => {
+ ensureComposerDraftsLoaded();
+ }, []);
+ return selection;
+}
diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts
index a355dddcd4..1313f202fa 100644
--- a/apps/mobile/src/state/use-thread-pr.test.ts
+++ b/apps/mobile/src/state/use-thread-pr.test.ts
@@ -17,7 +17,7 @@ describe("presentThreadPr", () => {
expect(presentThreadPr(pullRequest, undefined)).toMatchObject({
label: "3774",
accessibilityLabel: "#3774 pull request merged",
- textClassName: "text-violet-600 dark:text-violet-400",
+ textClassName: "text-adaptive-violet-600-400",
});
});
diff --git a/apps/mobile/uniwind-types.d.ts b/apps/mobile/uniwind-types.d.ts
index cc099419a9..22856ab57f 100644
--- a/apps/mobile/uniwind-types.d.ts
+++ b/apps/mobile/uniwind-types.d.ts
@@ -3,7 +3,7 @@
declare module 'uniwind' {
export interface UniwindConfig {
- themes: readonly ['light', 'dark']
+ themes: readonly ['light', 'dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark']
}
}
diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts
index 081e8c2ee8..5779cc5918 100644
--- a/apps/server/integration/OrchestrationEngineHarness.integration.ts
+++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts
@@ -383,7 +383,7 @@ export const makeOrchestrationIntegrationHarness = (
Layer.provideMerge(
Layer.succeed(ThreadDeletionReactor, {
start: () => Effect.void,
- drain: Effect.void,
+ drainThrough: () => Effect.void,
}),
),
Layer.provideMerge(
diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts
index 1add889c6b..4c483c34a8 100644
--- a/apps/server/scripts/acp-mock-agent.ts
+++ b/apps/server/scripts/acp-mock-agent.ts
@@ -20,7 +20,15 @@ const emitThoughtChunks = process.env.T3_ACP_EMIT_THOUGHT_CHUNKS === "1";
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
+const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1";
+const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1";
const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";
+const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1";
+const emitXAiAskUserQuestionThenHang =
+ process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1";
+const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1";
+const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";
+const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1";
const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
@@ -40,12 +48,19 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
+const initialGrokReasoningEffort =
+ process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;
const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
const permissionOptionIds = {
allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
};
+const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1";
+const permissionRequestCount = Math.max(
+ 1,
+ Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1,
+);
const sessionId = "mock-session-1";
let currentModeId = "ask";
@@ -280,7 +295,13 @@ function modeState(): AcpSchema.SessionModeState {
}
const grokAcpModels: ReadonlyArray = [
- { modelId: "grok-build", name: "Grok Build" },
+ {
+ modelId: "grok-build",
+ name: "Grok Build",
+ ...(initialGrokReasoningEffort
+ ? { _meta: { reasoningEffort: initialGrokReasoningEffort } }
+ : {}),
+ },
{ modelId: "grok-mock-alt", name: "Grok Mock Alt" },
];
@@ -523,6 +544,68 @@ const program = Effect.gen(function* () {
return yield* Effect.never;
}
+ if (emitXAiRateLimitThenHang) {
+ writeJsonRpcNotification("_x.ai/session/prompt_complete", {
+ sessionId: requestedSessionId,
+ promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1",
+ stopReason: "rate_limit",
+ agentResult: null,
+ });
+ return yield* Effect.never;
+ }
+
+ if (emitContentThenHang) {
+ yield* agent.client.sessionUpdate({
+ sessionId: requestedSessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: { type: "text", text: "partial before stall" },
+ },
+ });
+ return yield* Effect.never;
+ }
+
+ if (emitPlanThenHang) {
+ yield* agent.client.sessionUpdate({
+ sessionId: requestedSessionId,
+ update: {
+ sessionUpdate: "plan",
+ entries: [
+ {
+ content: "Wait for more ACP progress",
+ priority: "high",
+ status: "in_progress",
+ },
+ ],
+ },
+ });
+ return yield* Effect.never;
+ }
+
+ if (emitActiveToolThenHang) {
+ const toolCallId = "tool-call-long-running-1";
+ yield* agent.client.sessionUpdate({
+ sessionId: requestedSessionId,
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId,
+ title: "Long-running tool",
+ kind: "execute",
+ status: "pending",
+ rawInput: { command: ["long-running-tool"] },
+ },
+ });
+ yield* agent.client.sessionUpdate({
+ sessionId: requestedSessionId,
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId,
+ status: "in_progress",
+ },
+ });
+ return yield* Effect.never;
+ }
+
if (emitXAiPromptCompleteThenHang) {
writeJsonRpcNotification("session/update", {
sessionId: requestedSessionId,
@@ -685,37 +768,58 @@ const program = Effect.gen(function* () {
},
});
- const permission = yield* agent.client.requestPermission({
- sessionId: requestedSessionId,
- toolCall: {
- toolCallId,
- title: "`cat server/package.json`",
- kind: "execute",
- status: "pending",
- content: [
- {
- type: "content",
- content: {
- type: "text",
- text: "Not in allowlist: cat server/package.json",
+ const permissionOptions: Array = [
+ { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
+ ...(omitAllowAlways
+ ? []
+ : [
+ {
+ optionId: permissionOptionIds.allowAlways,
+ name: "Allow always",
+ kind: "allow_always" as const,
},
+ ]),
+ { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
+ ];
+
+ let cancelled = cancelledSessions.delete(requestedSessionId);
+ for (let index = 0; index < permissionRequestCount; index++) {
+ const command =
+ index > 0
+ ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json")
+ : "cat server/package.json";
+ const permission = yield* agent.client.requestPermission({
+ sessionId: requestedSessionId,
+ toolCall: {
+ toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`,
+ title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``,
+ kind: "execute",
+ status: "pending",
+ rawInput: {
+ variant: "Bash",
+ command,
+ description: index === 0 ? "Read package metadata" : "Read it again",
},
- ],
- },
- options: [
- { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
- {
- optionId: permissionOptionIds.allowAlways,
- name: "Allow always",
- kind: "allow_always",
+ content: [
+ {
+ type: "content",
+ content: {
+ type: "text",
+ text: `Not in allowlist: ${command}`,
+ },
+ },
+ ],
},
- { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
- ],
- });
-
- const cancelled =
- cancelledSessions.delete(requestedSessionId) ||
- permission.outcome.outcome === "cancelled";
+ options: permissionOptions,
+ });
+ cancelled =
+ cancelled ||
+ cancelledSessions.delete(requestedSessionId) ||
+ permission.outcome.outcome === "cancelled";
+ if (cancelled) {
+ break;
+ }
+ }
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
@@ -802,7 +906,7 @@ const program = Effect.gen(function* () {
return { stopReason: "end_turn" };
}
- if (emitXAiAskUserQuestion) {
+ if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) {
const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
method: "x.ai/ask_user_question",
params: {
@@ -836,6 +940,84 @@ const program = Effect.gen(function* () {
throw new Error("Expected accepted _x.ai/ask_user_question response answers.");
}
+ if (emitXAiAskUserQuestionThenHang) {
+ return yield* Effect.never;
+ }
+
+ return { stopReason: "end_turn" };
+ }
+
+ if (emitXAiPlanMdWrite) {
+ // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it.
+ const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok";
+ const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`;
+ const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n";
+ // enter_plan_mode first so the adapter arms planModeActive.
+ yield* agent.client.sessionUpdate({
+ sessionId: requestedSessionId,
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "enter-plan-mode-1",
+ title: "enter_plan_mode",
+ kind: "other",
+ status: "completed",
+ rawInput: { variant: "EnterPlanMode" },
+ },
+ });
+ yield* agent.client.sessionUpdate({
+ sessionId: requestedSessionId,
+ update: {
+ sessionUpdate: "tool_call",
+ toolCallId: "plan-md-write-1",
+ title: "write",
+ kind: "edit",
+ status: "pending",
+ rawInput: { file_path: planPath, content: planBody },
+ },
+ });
+ yield* agent.client.sessionUpdate({
+ sessionId: requestedSessionId,
+ update: {
+ sessionUpdate: "tool_call_update",
+ toolCallId: "plan-md-write-1",
+ kind: "edit",
+ status: "completed",
+ title: `Write \`${planPath}\``,
+ rawInput: { file_path: planPath, content: planBody },
+ content: [
+ {
+ type: "diff",
+ path: planPath,
+ oldText: "",
+ newText: planBody,
+ },
+ ],
+ },
+ });
+ return { stopReason: "end_turn" };
+ }
+
+ if (emitXAiExitPlanMode) {
+ const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", {
+ method: "x.ai/exit_plan_mode",
+ params: {
+ sessionId: requestedSessionId,
+ toolCallId: "exit-plan-mode-tool-call-1",
+ planContent: "# Exit plan\n\n- Step one\n- Step two\n",
+ },
+ });
+ if (typeof result !== "object" || result === null || !("outcome" in result)) {
+ throw new Error("Expected _x.ai/exit_plan_mode response outcome.");
+ }
+ if (
+ result.outcome !== "abandoned" &&
+ result.outcome !== "approved" &&
+ result.outcome !== "request_changes"
+ ) {
+ throw new Error(
+ `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`,
+ );
+ }
return { stopReason: "end_turn" };
}
diff --git a/apps/server/scripts/cliErrors.test.ts b/apps/server/scripts/cliErrors.test.ts
deleted file mode 100644
index 91754290db..0000000000
--- a/apps/server/scripts/cliErrors.test.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { assert, describe, it } from "@effect/vitest";
-
-import { ServerCliBuildAssetMissingError, ServerCliCommandExitError } from "./cliErrors.ts";
-
-describe("server CLI errors", () => {
- it("preserves failed command context without changing its message", () => {
- const error = new ServerCliCommandExitError({
- command: "vp",
- args: ["pm", "publish"],
- cwd: "/repo",
- exitCode: 17,
- });
-
- assert.equal(error._tag, "ServerCliCommandExitError");
- assert.equal(error.command, "vp");
- assert.deepEqual(error.args, ["pm", "publish"]);
- assert.equal(error.cwd, "/repo");
- assert.equal(error.exitCode, 17);
- assert.equal(error.message, "Command exited with non-zero exit code (17)");
- });
-
- it("preserves a representative missing asset path", () => {
- const error = new ServerCliBuildAssetMissingError({ assetPath: "/repo/server.mjs" });
-
- assert.equal(error.assetPath, "/repo/server.mjs");
- assert.equal(
- error.message,
- "Missing build asset: /repo/server.mjs. Run the build subcommand first.",
- );
- });
-});
diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts
index 232a41e5a9..dff11524ed 100644
--- a/apps/server/src/assets/AssetAccess.ts
+++ b/apps/server/src/assets/AssetAccess.ts
@@ -37,7 +37,7 @@ import {
timingSafeEqualBase64Url,
} from "../auth/utils.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
-import { resolveAttachmentPathById } from "../attachmentStore.ts";
+import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts";
import * as ServerConfig from "../config.ts";
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
@@ -79,6 +79,13 @@ const AssetClaimsSchema = Schema.Union([
version: Schema.Literal(1),
kind: Schema.Literal("attachment"),
attachmentId: Schema.String,
+ /** Decided at mint time. Absent tokens (from before this field) serve
+ inline, which is only ever the image case. */
+ download: Schema.optionalKey(Schema.Boolean),
+ /** Display name and mime the caller supplied at mint time; drive the
+ download filename and Content-Type. */
+ fileName: Schema.optionalKey(Schema.String),
+ mimeType: Schema.optionalKey(Schema.String),
expiresAt: Schema.Number,
}),
Schema.Struct({
@@ -101,7 +108,13 @@ const AssetClaimsJson = Schema.fromJsonString(AssetClaimsSchema);
const decodeAssetClaims = Schema.decodeUnknownOption(AssetClaimsJson);
const encodeAssetClaims = Schema.encodeSync(AssetClaimsJson);
-export type ResolvedAsset = { readonly kind: "file"; readonly path: string };
+export type ResolvedAsset = {
+ readonly kind: "file";
+ readonly path: string;
+ readonly download?: boolean;
+ readonly fileName?: string;
+ readonly mimeType?: string;
+};
function decodeClaims(encodedPayload: string): AssetClaims | null {
try {
@@ -286,13 +299,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
resource: input.resource,
});
}
+ // Generic files carry their extension inside the attachment id (that
+ // shape resolves the on-disk path); images do not. Only generic files
+ // download, images render inline in chat.
+ const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
claims = {
version: 1,
kind: "attachment",
attachmentId: input.resource.attachmentId,
+ ...(isGenericFile ? { download: true } : {}),
+ ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
+ ...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}),
expiresAt,
};
- fileName = path.basename(attachmentPath);
+ fileName = input.resource.fileName ?? path.basename(attachmentPath);
break;
}
case "project-favicon": {
@@ -464,7 +484,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
Effect.orElseSucceed(() => Option.none()),
);
return Option.isSome(info) && info.value.type === "File"
- ? ({ kind: "file", path: attachmentPath } satisfies ResolvedAsset)
+ ? ({
+ kind: "file",
+ path: attachmentPath,
+ ...(claims.download ? { download: true } : {}),
+ ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}),
+ ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}),
+ } satisfies ResolvedAsset)
: null;
}
diff --git a/apps/server/src/assets/AttachmentUpload.test.ts b/apps/server/src/assets/AttachmentUpload.test.ts
index cb08d5e4b2..6fffa1d1f9 100644
--- a/apps/server/src/assets/AttachmentUpload.test.ts
+++ b/apps/server/src/assets/AttachmentUpload.test.ts
@@ -4,11 +4,16 @@ import * as NodePath from "node:path";
import * as NodeServices from "@effect/platform-node/NodeServices";
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 Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
+import { base64UrlEncode, signPayload } from "../auth/utils.ts";
import * as ServerConfig from "../config.ts";
import { parseThreadSegmentFromAttachmentId } from "../attachmentStore.ts";
import {
@@ -30,6 +35,19 @@ const uploadInput = {
sizeBytes: 6,
} as const;
+const LegacyAttachmentUploadClaims = Schema.Struct({
+ version: Schema.Literal(1),
+ kind: Schema.Literal("attachment-upload"),
+ attachmentId: Schema.String,
+ name: Schema.String,
+ mimeType: Schema.String,
+ sizeBytes: Schema.Number,
+ expiresAt: Schema.Number,
+});
+const encodeLegacyAttachmentUploadClaims = Schema.encodeEffect(
+ Schema.fromJsonString(LegacyAttachmentUploadClaims),
+);
+
describe("AttachmentUpload", () => {
it.effect("signs the attachment metadata and validates the upload token", () =>
Effect.gen(function* () {
@@ -59,6 +77,31 @@ describe("AttachmentUpload", () => {
}).pipe(Effect.provide(testLayer)),
);
+ it.effect("accepts unexpired image upload tokens issued before file support", () =>
+ Effect.gen(function* () {
+ const issued = yield* issueAttachmentUploadUrl(uploadInput);
+ const secretStore = yield* ServerSecretStore.ServerSecretStore;
+ const secret = yield* secretStore.getOrCreateRandom("asset-access-signing-key", 32);
+ const encodedPayload = base64UrlEncode(
+ yield* encodeLegacyAttachmentUploadClaims({
+ version: 1,
+ kind: "attachment-upload",
+ attachmentId: issued.attachmentId,
+ name: uploadInput.name,
+ mimeType: uploadInput.mimeType,
+ sizeBytes: uploadInput.sizeBytes,
+ expiresAt: issued.expiresAt,
+ }),
+ );
+ const legacyToken = `${encodedPayload}.${signPayload(encodedPayload, secret)}`;
+
+ expect(yield* validateAttachmentUploadToken(legacyToken)).toMatchObject({
+ type: "image",
+ attachmentId: issued.attachmentId,
+ });
+ }).pipe(Effect.provide(testLayer)),
+ );
+
it.effect("rejects expired upload tokens", () =>
Effect.gen(function* () {
const issued = yield* issueAttachmentUploadUrl(uploadInput);
@@ -108,6 +151,85 @@ describe("AttachmentUpload", () => {
}).pipe(Effect.provide(testLayer)),
);
+ it.effect("streams generic files to a path with their original extension", () =>
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const issued = yield* issueAttachmentUploadUrl({
+ type: "file",
+ name: "report.PDF",
+ mimeType: "application/pdf",
+ sizeBytes: 6,
+ });
+ const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
+ const claims = yield* validateAttachmentUploadToken(token);
+ if (!claims) {
+ throw new Error("Expected valid upload claims.");
+ }
+
+ expect(
+ yield* storeAttachmentUpload(
+ claims,
+ Stream.make(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])),
+ ),
+ ).toEqual({ ok: true });
+ expect(issued.attachmentId).toMatch(/-pdf$/);
+ expect(
+ NodeFS.readFileSync(NodePath.join(config.attachmentsDir, `${issued.attachmentId}.pdf`)),
+ ).toEqual(Buffer.from([1, 2, 3, 4, 5, 6]));
+
+ yield* deletePendingAttachment(issued.attachmentId);
+ expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
+ }).pipe(Effect.provide(testLayer)),
+ );
+
+ it.effect("removes partial streamed uploads that exceed their signed size", () =>
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const issued = yield* issueAttachmentUploadUrl(uploadInput);
+ const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
+ const claims = yield* validateAttachmentUploadToken(token);
+ if (!claims) {
+ throw new Error("Expected valid upload claims.");
+ }
+
+ expect(yield* storeAttachmentUpload(claims, Stream.make(new Uint8Array(7)))).toMatchObject({
+ ok: false,
+ status: 400,
+ });
+ expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
+ }).pipe(Effect.provide(testLayer)),
+ );
+
+ it.effect("removes partial streamed uploads when the upload is interrupted", () =>
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const issued = yield* issueAttachmentUploadUrl(uploadInput);
+ const token = issued.relativeUrl.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
+ const claims = yield* validateAttachmentUploadToken(token);
+ if (!claims) {
+ throw new Error("Expected valid upload claims.");
+ }
+
+ const nextChunkRequested = yield* Deferred.make();
+ const body = Stream.make(new Uint8Array([1, 2, 3])).pipe(
+ Stream.concat(
+ Stream.fromEffect(
+ Deferred.succeed(nextChunkRequested, undefined).pipe(Effect.andThen(Effect.never)),
+ ),
+ ),
+ );
+ const upload = yield* storeAttachmentUpload(claims, body).pipe(Effect.forkScoped);
+
+ yield* Deferred.await(nextChunkRequested);
+ expect(
+ NodeFS.readdirSync(config.attachmentsDir).filter((entry) => entry.endsWith(".part")),
+ ).toHaveLength(1);
+
+ yield* Fiber.interrupt(upload);
+ expect(NodeFS.readdirSync(config.attachmentsDir)).toEqual([]);
+ }).pipe(Effect.provide(testLayer)),
+ );
+
it.effect("deletes pending uploads without deleting thread-owned copies", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
diff --git a/apps/server/src/assets/AttachmentUpload.ts b/apps/server/src/assets/AttachmentUpload.ts
index 6142b69d73..ba3539a3df 100644
--- a/apps/server/src/assets/AttachmentUpload.ts
+++ b/apps/server/src/assets/AttachmentUpload.ts
@@ -12,8 +12,11 @@ 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 * as Stream from "effect/Stream";
+import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
import {
+ attachmentFileExtension,
createPendingAttachmentId,
parseThreadSegmentFromAttachmentId,
PENDING_ATTACHMENT_THREAD_SEGMENT,
@@ -41,6 +44,9 @@ const lastPendingSweepByDirectory = new Map();
const AttachmentUploadClaims = Schema.Struct({
version: Schema.Literal(1),
kind: Schema.Literal("attachment-upload"),
+ type: Schema.Literals(["image", "file"]).pipe(
+ Schema.withDecodingDefault(Effect.succeed("image" as const)),
+ ),
attachmentId: Schema.String,
name: Schema.String,
mimeType: Schema.String,
@@ -89,12 +95,16 @@ export const issueAttachmentUploadUrl = Effect.fn("AttachmentUpload.issueUrl")(f
}
}
- const attachmentId = createPendingAttachmentId();
+ const attachmentType = input.type ?? "image";
+ const attachmentId = createPendingAttachmentId(
+ attachmentType === "file" ? attachmentFileExtension(input.name) : undefined,
+ );
const expiresAt = nowMs + ATTACHMENT_UPLOAD_URL_TTL_MS;
const encodedPayload = base64UrlEncode(
encodeAttachmentUploadClaims({
version: 1,
kind: "attachment-upload",
+ type: attachmentType,
attachmentId,
name: input.name,
mimeType: input.mimeType,
@@ -141,18 +151,21 @@ export type StoreAttachmentUploadResult =
export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(function* (
claims: AttachmentUploadClaims,
- bytes: Uint8Array,
+ body: Uint8Array | HttpServerRequest.HttpServerRequest["stream"],
) {
- if (bytes.byteLength !== claims.sizeBytes) {
+ if (body instanceof Uint8Array && body.byteLength !== claims.sizeBytes) {
return {
ok: false,
status: 400,
- detail: `Body was ${bytes.byteLength} bytes, expected ${claims.sizeBytes}.`,
+ detail: `Body was ${body.byteLength} bytes, expected ${claims.sizeBytes}.`,
} satisfies StoreAttachmentUploadResult;
}
const config = yield* ServerConfig.ServerConfig;
- const extension = inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
+ const extension =
+ claims.type === "file"
+ ? attachmentFileExtension(claims.name)
+ : inferImageExtension({ mimeType: claims.mimeType, fileName: claims.name });
const relativePath = `${claims.attachmentId}${extension}`;
const finalPath = resolveAttachmentRelativePath({
attachmentsDir: config.attachmentsDir,
@@ -168,21 +181,34 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
+ let receivedBytes = 0;
+ const bodyStream = body instanceof Uint8Array ? Stream.make(body) : body;
return yield* Effect.gen(function* () {
yield* fileSystem.makeDirectory(path.dirname(finalPath), { recursive: true });
- yield* fileSystem.writeFile(partPath, bytes);
+ yield* Stream.run(
+ bodyStream.pipe(
+ Stream.takeWhile((chunk) => {
+ receivedBytes += chunk.byteLength;
+ return receivedBytes <= claims.sizeBytes;
+ }),
+ ),
+ fileSystem.sink(partPath),
+ );
+ if (receivedBytes !== claims.sizeBytes) {
+ return {
+ ok: false,
+ status: 400,
+ detail: `Body was ${receivedBytes} bytes, expected ${claims.sizeBytes}.`,
+ } satisfies StoreAttachmentUploadResult;
+ }
yield* fileSystem.rename(partPath, finalPath);
return { ok: true } satisfies StoreAttachmentUploadResult;
}).pipe(
Effect.catch((cause) =>
- fileSystem.remove(partPath, { force: true }).pipe(
- Effect.orElseSucceed(() => undefined),
- Effect.andThen(
- Effect.logError("Failed to persist attachment upload.", {
- attachmentId: claims.attachmentId,
- cause,
- }),
- ),
+ Effect.logError("Failed to persist attachment upload.", {
+ attachmentId: claims.attachmentId,
+ cause,
+ }).pipe(
Effect.as({
ok: false,
status: 500,
@@ -190,6 +216,9 @@ export const storeAttachmentUpload = Effect.fn("AttachmentUpload.store")(functio
} satisfies StoreAttachmentUploadResult),
),
),
+ Effect.ensuring(
+ fileSystem.remove(partPath, { force: true }).pipe(Effect.orElseSucceed(() => undefined)),
+ ),
);
});
diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts
index 5e782e5540..59374f18ba 100644
--- a/apps/server/src/attachmentStore.test.ts
+++ b/apps/server/src/attachmentStore.test.ts
@@ -6,9 +6,11 @@ import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";
import {
+ attachmentFileExtension,
createAttachmentId,
createPendingAttachmentId,
parseAttachmentUuid,
+ parseAttachmentFileExtension,
planAttachmentClaim,
parseThreadSegmentFromAttachmentId,
resolveAttachmentPathById,
@@ -58,6 +60,21 @@ describe("attachmentStore", () => {
);
});
+ it("preserves safe file extensions in attachment ids and paths", () => {
+ const attachmentId = createPendingAttachmentId(".PDF");
+
+ expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("pending");
+ expect(parseAttachmentUuid(attachmentId)).toMatch(/^[a-f0-9-]{36}$/);
+ expect(parseAttachmentFileExtension(attachmentId)).toBe("pdf");
+ expect(attachmentFileExtension("report.PDF")).toBe(".pdf");
+ expect(attachmentFileExtension("report")).toBe(".bin");
+ expect(attachmentFileExtension("report.extensiontoolong")).toBe(".bin");
+ // ".part" is the in-flight upload suffix; storing it would make the file
+ // look like a stale partial to the sweep.
+ expect(attachmentFileExtension("archive.part")).toBe(".bin");
+ expect(createAttachmentId("x".repeat(80), ".abcdefghij")?.length).toBeLessThanOrEqual(128);
+ });
+
it("resolves attachment path by id using the extension that exists on disk", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"),
@@ -92,6 +109,21 @@ describe("attachmentStore", () => {
}
});
+ it("resolves generic attachments without scanning the attachment directory", () => {
+ const attachmentsDir = NodeFS.mkdtempSync(
+ NodePath.join(NodeOS.tmpdir(), "t3code-file-attachment-"),
+ );
+ try {
+ const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-zip";
+ const archivePath = NodePath.join(attachmentsDir, `${attachmentId}.zip`);
+ NodeFS.writeFileSync(archivePath, Buffer.from("archive"));
+
+ expect(resolveAttachmentPathById({ attachmentsDir, attachmentId })).toBe(archivePath);
+ } finally {
+ NodeFS.rmSync(attachmentsDir, { recursive: true, force: true });
+ }
+ });
+
it("plans pending attachment claims with direct filename lookups", () => {
const attachmentsDir = NodeFS.mkdtempSync(
NodePath.join(NodeOS.tmpdir(), "t3code-attachment-claim-"),
@@ -147,15 +179,17 @@ describe("attachmentStore", () => {
const oldTimeSeconds = (now - 2 * 24 * 60 * 60 * 1000) / 1000;
const uuid = "00000000-0000-4000-8000-000000000002";
const pendingPath = NodePath.join(attachmentsDir, `pending-${uuid}.png`);
+ const pendingFilePath = NodePath.join(attachmentsDir, `pending-${uuid}-pdf.pdf`);
const threadPath = NodePath.join(attachmentsDir, `thread-1-${uuid}.png`);
const partialPath = NodePath.join(attachmentsDir, `${uuid}.part`);
- for (const filePath of [pendingPath, threadPath, partialPath]) {
+ for (const filePath of [pendingPath, pendingFilePath, threadPath, partialPath]) {
NodeFS.writeFileSync(filePath, Buffer.from("pixels"));
NodeFS.utimesSync(filePath, oldTimeSeconds, oldTimeSeconds);
}
- expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 2 });
+ expect(sweepStalePendingAttachments({ attachmentsDir, nowMs: now })).toEqual({ deleted: 3 });
expect(NodeFS.existsSync(pendingPath)).toBe(false);
+ expect(NodeFS.existsSync(pendingFilePath)).toBe(false);
expect(NodeFS.existsSync(partialPath)).toBe(false);
expect(NodeFS.existsSync(threadPath)).toBe(true);
} finally {
diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts
index d0334bce09..261b094645 100644
--- a/apps/server/src/attachmentStore.ts
+++ b/apps/server/src/attachmentStore.ts
@@ -15,8 +15,9 @@ const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80;
const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*";
const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
+const ATTACHMENT_ID_FILE_EXTENSION_PATTERN = "[a-z0-9]{1,10}";
const ATTACHMENT_ID_PATTERN = new RegExp(
- `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`,
+ `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})(?:-(${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}))?$`,
"i",
);
@@ -39,8 +40,28 @@ export function toSafeThreadAttachmentSegment(threadId: string): string | null {
return segment === PENDING_ATTACHMENT_THREAD_SEGMENT ? "_pending" : segment;
}
-export function createPendingAttachmentId(): string {
- return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}`;
+export function attachmentFileExtension(fileName: string): string {
+ const extension = NodePath.extname(fileName).toLowerCase();
+ // ".part" is reserved for in-flight uploads; a stored "archive.part" would
+ // look stale to sweepStalePendingAttachments and get deleted.
+ if (extension === ".part" || !/^\.[a-z0-9]{1,10}$/.test(extension)) {
+ return ".bin";
+ }
+ return extension;
+}
+
+function attachmentIdExtensionSuffix(extension: string | undefined): string {
+ if (!extension) {
+ return "";
+ }
+ const normalized = extension.replace(/^\./, "").toLowerCase();
+ return new RegExp(`^${ATTACHMENT_ID_FILE_EXTENSION_PATTERN}$`).test(normalized)
+ ? `-${normalized}`
+ : "-bin";
+}
+
+export function createPendingAttachmentId(extension?: string): string {
+ return `${PENDING_ATTACHMENT_THREAD_SEGMENT}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
}
export function parseAttachmentUuid(attachmentId: string): string | null {
@@ -51,12 +72,20 @@ export function parseAttachmentUuid(attachmentId: string): string | null {
return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase() ?? null;
}
-export function createAttachmentId(threadId: string): string | null {
+export function parseAttachmentFileExtension(attachmentId: string): string | null {
+ const normalizedId = normalizeAttachmentRelativePath(attachmentId);
+ if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
+ return null;
+ }
+ return normalizedId.match(ATTACHMENT_ID_PATTERN)?.[3]?.toLowerCase() ?? null;
+}
+
+export function createAttachmentId(threadId: string, extension?: string): string | null {
const threadSegment = toSafeThreadAttachmentSegment(threadId);
if (!threadSegment) {
return null;
}
- return `${threadSegment}-${NodeCrypto.randomUUID()}`;
+ return `${threadSegment}-${NodeCrypto.randomUUID()}${attachmentIdExtensionSuffix(extension)}`;
}
export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null {
@@ -71,7 +100,8 @@ export function parseThreadSegmentFromAttachmentId(attachmentId: string): string
return match[1]?.toLowerCase() ?? null;
}
-export function attachmentRelativePath(attachment: ChatAttachment): string {
+/** Null for attachment types this build does not know; callers skip those. */
+export function attachmentRelativePath(attachment: ChatAttachment): string | null {
switch (attachment.type) {
case "image": {
const extension = inferImageExtension({
@@ -80,6 +110,10 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
});
return `${attachment.id}${extension}`;
}
+ case "file":
+ return `${attachment.id}${attachmentFileExtension(attachment.name)}`;
+ default:
+ return null;
}
}
@@ -87,9 +121,13 @@ export function resolveAttachmentPath(input: {
readonly attachmentsDir: string;
readonly attachment: ChatAttachment;
}): string | null {
+ const relativePath = attachmentRelativePath(input.attachment);
+ if (!relativePath) {
+ return null;
+ }
return resolveAttachmentRelativePath({
attachmentsDir: input.attachmentsDir,
- relativePath: attachmentRelativePath(input.attachment),
+ relativePath,
});
}
@@ -101,6 +139,14 @@ export function resolveAttachmentPathById(input: {
if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) {
return null;
}
+ const fileExtension = parseAttachmentFileExtension(normalizedId);
+ if (fileExtension) {
+ const filePath = resolveAttachmentRelativePath({
+ attachmentsDir: input.attachmentsDir,
+ relativePath: `${normalizedId}.${fileExtension.toLowerCase()}`,
+ });
+ return filePath && NodeFS.existsSync(filePath) ? filePath : null;
+ }
for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {
const maybePath = resolveAttachmentRelativePath({
attachmentsDir: input.attachmentsDir,
@@ -147,7 +193,8 @@ export function planAttachmentClaim(input: {
if (!currentPath) {
return { ok: false, reason: "attachment not found (removed or expired)" };
}
- const finalId = createAttachmentId(input.threadId);
+ const fileExtension = parseAttachmentFileExtension(input.attachmentId) ?? undefined;
+ const finalId = createAttachmentId(input.threadId, fileExtension);
if (!finalId) {
return { ok: false, reason: "failed to create attachment id" };
}
diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts
index eb05634214..f5d244dd96 100644
--- a/apps/server/src/auth/EnvironmentAuth.ts
+++ b/apps/server/src/auth/EnvironmentAuth.ts
@@ -16,6 +16,8 @@ import {
type ServerAuthDescriptor,
type ServerAuthSessionMethod,
type AuthWebSocketTicketResult,
+ DpopFailureReason,
+ type DpopFailureReason as DpopFailureReasonType,
} from "@t3tools/contracts";
import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
import * as Context from "effect/Context";
@@ -347,6 +349,7 @@ export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass
error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential";
+export const serverAuthDpopFailureReason = (
+ error: ServerAuthCredentialError,
+): DpopFailureReasonType | undefined =>
+ error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined;
+
export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()(
"ServerAuthInvalidScopeError",
{},
@@ -606,6 +614,7 @@ export const make = Effect.gen(function* () {
return Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "DPoP-bound access token requires DPoP authorization.",
+ dpopFailureReason: "invalid_proof",
}),
);
}
@@ -623,6 +632,7 @@ export const make = Effect.gen(function* () {
return Effect.fail(
new ServerAuthInvalidCredentialError({
diagnostic: "DPoP authorization requires a proof-bound access token.",
+ dpopFailureReason: "invalid_proof",
}),
);
}
diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts
index fa75c407b0..ea8d1cd99d 100644
--- a/apps/server/src/auth/dpop.test.ts
+++ b/apps/server/src/auth/dpop.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import * as PlatformError from "effect/PlatformError";
import { SecretStorePersistError } from "./ServerSecretStore.ts";
-import { mapDpopReplayStoreError } from "./dpop.ts";
+import { mapDpopFailureReason, mapDpopReplayStoreError } from "./dpop.ts";
const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
new SecretStorePersistError({
@@ -23,6 +23,7 @@ describe("mapDpopReplayStoreError", () => {
expect(error._tag).toBe("ServerAuthInvalidCredentialError");
if (error._tag === "ServerAuthInvalidCredentialError") {
expect(error.cause).toBe(cause);
+ expect(error.dpopFailureReason).toBe("replay");
}
});
@@ -35,3 +36,23 @@ describe("mapDpopReplayStoreError", () => {
}
});
});
+
+describe("mapDpopFailureReason", () => {
+ it("maps verifier failures to safe client-facing categories", () => {
+ const mappings = [
+ ["time_window", "time_window"],
+ ["key_mismatch", "key_mismatch"],
+ ["method_mismatch", "request_mismatch"],
+ ["url_mismatch", "request_mismatch"],
+ ["access_token_hash_mismatch", "token_mismatch"],
+ ["missing_proof", "invalid_proof"],
+ ["malformed_proof", "invalid_proof"],
+ ["invalid_signature", "invalid_proof"],
+ ["invalid_proof", "invalid_proof"],
+ ] as const;
+
+ for (const [code, expected] of mappings) {
+ expect(mapDpopFailureReason(code)).toBe(expected);
+ }
+ });
+});
diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts
index f19984eb36..43f90e4409 100644
--- a/apps/server/src/auth/dpop.ts
+++ b/apps/server/src/auth/dpop.ts
@@ -1,4 +1,8 @@
-import { verifyDpopProof } from "@t3tools/shared/dpop";
+import {
+ type DpopVerificationFailureCode as DpopVerificationFailureCodeType,
+ verifyDpopProof,
+} from "@t3tools/shared/dpop";
+import type { DpopFailureReason } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
@@ -14,12 +18,32 @@ import {
} from "./EnvironmentAuth.ts";
import * as ServerSecretStore from "./ServerSecretStore.ts";
+export const mapDpopFailureReason = (code: DpopVerificationFailureCodeType): DpopFailureReason => {
+ switch (code) {
+ case "time_window":
+ return "time_window";
+ case "key_mismatch":
+ return "key_mismatch";
+ case "method_mismatch":
+ case "url_mismatch":
+ return "request_mismatch";
+ case "access_token_hash_mismatch":
+ return "token_mismatch";
+ case "missing_proof":
+ case "malformed_proof":
+ case "invalid_signature":
+ case "invalid_proof":
+ return "invalid_proof";
+ }
+};
+
export const mapDpopReplayStoreError = (
error: ServerSecretStore.SecretStoreError,
): ServerAuthInvalidCredentialError | ServerAuthInternalError =>
ServerSecretStore.isSecretAlreadyExistsError(error)
? new ServerAuthInvalidCredentialError({
diagnostic: "DPoP proof replayed.",
+ dpopFailureReason: "replay",
cause: error,
})
: new ServerAuthDpopReplayStateRecordError({
@@ -49,8 +73,12 @@ export const verifyRequestDpopProof = (input: {
...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
});
if (!result.ok) {
+ yield* Effect.annotateCurrentSpan({
+ "environment.dpop.failure_code": result.code,
+ });
return yield* new ServerAuthInvalidCredentialError({
diagnostic: result.reason,
+ dpopFailureReason: mapDpopFailureReason(result.code),
});
}
const secretStore = yield* ServerSecretStore.ServerSecretStore;
@@ -80,7 +108,15 @@ export const verifyRequestDpopProof = (input: {
)
.pipe(
Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
- Effect.fail(mapDpopReplayStoreError(error)),
+ Effect.gen(function* () {
+ const mapped = mapDpopReplayStoreError(error);
+ if (mapped._tag === "ServerAuthInvalidCredentialError") {
+ yield* Effect.annotateCurrentSpan({
+ "environment.dpop.failure_code": mapped.dpopFailureReason,
+ });
+ }
+ return yield* Effect.fail(mapped);
+ }),
),
);
return result.thumbprint;
diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts
index 780aaabde2..58277141a9 100644
--- a/apps/server/src/auth/http.ts
+++ b/apps/server/src/auth/http.ts
@@ -22,7 +22,7 @@ import {
EnvironmentAuthenticatedAuth,
EnvironmentAuthenticatedPrincipal,
} from "@t3tools/contracts";
-import type { AuthEnvironmentScope } from "@t3tools/contracts";
+import type { AuthEnvironmentScope, DpopFailureReason } from "@t3tools/contracts";
import { parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as DateTime from "effect/DateTime";
@@ -95,10 +95,20 @@ export function annotateEnvironmentRequest(endpoint: string) {
});
}
-export function failEnvironmentAuthInvalid(reason: EnvironmentAuthInvalidReason) {
+export function failEnvironmentAuthInvalid(
+ reason: EnvironmentAuthInvalidReason,
+ dpopFailureReason?: DpopFailureReason,
+) {
return currentEnvironmentTraceId.pipe(
Effect.flatMap((traceId) =>
- Effect.fail(new EnvironmentAuthInvalidError({ code: "auth_invalid", reason, traceId })),
+ Effect.fail(
+ new EnvironmentAuthInvalidError({
+ code: "auth_invalid",
+ reason,
+ ...(dpopFailureReason === undefined ? {} : { dpopFailureReason }),
+ traceId,
+ }),
+ ),
),
);
}
@@ -180,7 +190,10 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(
const request = yield* HttpServerRequest.HttpServerRequest;
const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("internal_error", error),
@@ -244,7 +257,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
return result.response;
},
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("browser_session_issuance_failed", error),
@@ -278,9 +294,14 @@ export const authHttpApiLayer = HttpApiBuilder.group(
}
const proofKeyThumbprint = args.headers.dpop
? yield* verifyRequestDpopProof({ request }).pipe(
- Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>
+ Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
appendDpopChallengeHeader.pipe(
- Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
+ Effect.andThen(
+ failEnvironmentAuthInvalid(
+ "invalid_credential",
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
+ ),
),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
@@ -307,7 +328,10 @@ export const authHttpApiLayer = HttpApiBuilder.group(
},
traceRelayRequest,
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) =>
failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)),
diff --git a/apps/server/src/checkpointing/Errors.test.ts b/apps/server/src/checkpointing/Errors.test.ts
deleted file mode 100644
index 4c8b9c59cc..0000000000
--- a/apps/server/src/checkpointing/Errors.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { expect, it } from "@effect/vitest";
-import { ThreadId } from "@t3tools/contracts";
-
-import {
- CheckpointRefUnavailableError,
- CheckpointTurnRangeUnavailableError,
- CheckpointWorkspacePathMissingError,
-} from "./Errors.ts";
-
-const threadId = ThreadId.make("thread-1");
-
-it("derives checkpoint messages from structured context", () => {
- const range = new CheckpointTurnRangeUnavailableError({
- operation: "CheckpointDiffQuery.getTurnDiff",
- threadId,
- requestedTurnCount: 4,
- availableTurnCount: 2,
- });
- const checkpoint = new CheckpointRefUnavailableError({
- operation: "CheckpointDiffQuery.getTurnDiff",
- threadId,
- turnCount: 2,
- checkpoint: "to",
- });
- const workspace = new CheckpointWorkspacePathMissingError({
- operation: "CheckpointDiffQuery.getFullThreadDiff",
- threadId,
- });
-
- expect(range.message).toBe(
- "Checkpoint unavailable for thread thread-1 turn 4: Turn diff range exceeds current turn count: requested 4, current 2.",
- );
- expect(checkpoint.message).toBe(
- "Checkpoint unavailable for thread thread-1 turn 2: Checkpoint ref is unavailable for turn 2.",
- );
- expect(workspace.message).toBe(
- "Checkpoint invariant violation in CheckpointDiffQuery.getFullThreadDiff: Workspace path missing for thread 'thread-1' when computing full thread diff.",
- );
-});
diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts
index 2f03859878..3d5a8722e5 100644
--- a/apps/server/src/environment/ServerEnvironment.test.ts
+++ b/apps/server/src/environment/ServerEnvironment.test.ts
@@ -93,6 +93,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.repositoryIdentity).toBe(true);
expect(second.capabilities.connectionProbe).toBe(true);
expect(second.capabilities.attachmentUploads).toBe(true);
+ expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 });
expect(second.capabilities.pullRequests).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.threadPullRequestLinking).toBe(true);
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index 9d4e56aa5b..3c2bbb65d3 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -1,4 +1,8 @@
-import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
+import {
+ EnvironmentId,
+ PROVIDER_SEND_TURN_MAX_FILE_BYTES,
+ type ExecutionEnvironmentDescriptor,
+} from "@t3tools/contracts";
import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
@@ -147,6 +151,7 @@ export const make = Effect.gen(function* () {
repositoryIdentity: true,
connectionProbe: true,
attachmentUploads: true,
+ fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES },
pullRequests: true,
threadSettlement: true,
threadSnooze: true,
diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts
index f85de08d40..edb3967a07 100644
--- a/apps/server/src/http.test.ts
+++ b/apps/server/src/http.test.ts
@@ -1,7 +1,12 @@
import { expect, it } from "@effect/vitest";
import { describe } from "vite-plus/test";
-import { assetResponseHeaders, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
+import {
+ assetResponseHeaders,
+ downloadContentDisposition,
+ isLoopbackHostname,
+ resolveDevRedirectUrl,
+} from "./http.ts";
describe("http dev routing", () => {
it("treats localhost and loopback addresses as local", () => {
@@ -55,4 +60,79 @@ describe("assetResponseHeaders", () => {
"text/html; charset=utf-8",
);
});
+
+ it("downloads uploaded documents without executing their content", () => {
+ expect(assetResponseHeaders("/attachments/upload.html", { download: true })).toMatchObject({
+ "Content-Disposition": "attachment",
+ "Content-Security-Policy": "default-src 'none'; sandbox",
+ "Content-Type": "application/octet-stream",
+ });
+ });
+
+ it("serves the real filename and mime type when the claims carry them", () => {
+ expect(
+ assetResponseHeaders("/attachments/thread-1-abc-pdf.pdf", {
+ download: true,
+ fileName: "Q3 report.pdf",
+ mimeType: "application/pdf",
+ }),
+ ).toMatchObject({
+ "Content-Disposition": 'attachment; filename="Q3 report.pdf"',
+ "Content-Security-Policy": "default-src 'none'; sandbox",
+ "Content-Type": "application/pdf",
+ });
+ });
+
+ it("keeps renderable mime types as octet-stream downloads", () => {
+ for (const mimeType of [
+ "text/html",
+ "text/xml",
+ "image/svg+xml",
+ "application/xhtml+xml",
+ "application/rss+xml",
+ "APPLICATION/XML",
+ "IMAGE/SVG+XML",
+ "application/xml-dtd",
+ "application/xml-external-parsed-entity",
+ "not a mime",
+ ]) {
+ expect(
+ assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }),
+ ).toHaveProperty("Content-Type", "application/octet-stream");
+ }
+ });
+
+ it("preserves official Office Open XML mime types", () => {
+ for (const mimeType of [
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ]) {
+ expect(
+ assetResponseHeaders("/attachments/upload.bin", { download: true, mimeType }),
+ ).toHaveProperty("Content-Type", mimeType);
+ }
+ });
+});
+
+describe("downloadContentDisposition", () => {
+ it("quotes plain names and strips quotes and control characters", () => {
+ expect(downloadContentDisposition("report.pdf")).toBe('attachment; filename="report.pdf"');
+ expect(downloadContentDisposition('we"ird\n.pdf')).toBe('attachment; filename="we_ird_.pdf"');
+ });
+
+ it("adds an RFC 5987 encoded name for non-ASCII filenames", () => {
+ expect(downloadContentDisposition("répört.pdf")).toBe(
+ `attachment; filename="r_p_rt.pdf"; filename*=UTF-8''r%C3%A9p%C3%B6rt.pdf`,
+ );
+ expect(downloadContentDisposition("résumé'(*).pdf")).toBe(
+ `attachment; filename="r_sum_'(*).pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9%27%28%2A%29.pdf`,
+ );
+ });
+
+ it("does not throw on unpaired surrogates in the filename", () => {
+ expect(downloadContentDisposition("bad\ud800name.pdf")).toBe(
+ `attachment; filename="bad_name.pdf"; filename*=UTF-8''bad%EF%BF%BDname.pdf`,
+ );
+ });
});
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index 16a558560a..5bb6ba8dc3 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -13,6 +13,7 @@ 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 Stream from "effect/Stream";
import { cast } from "effect/Function";
import {
HttpBody,
@@ -52,15 +53,57 @@ const INDEX_HTML_FILE_NAME = "index.html";
const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
-export function assetResponseHeaders(filePath: string): Record {
+// Types a browser may render as a document if a proxy strips the disposition
+// header. Downloads of these fall back to octet-stream.
+const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
+const isSafeDownloadMimeType = (mimeType: string): boolean =>
+ DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
+ !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
+
+/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
+export function downloadContentDisposition(fileName?: string): string {
+ if (fileName === undefined) {
+ return "attachment";
+ }
+ // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.
+ // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls.
+ const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_");
+ const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");
+ const needsExtended = asciiFallback !== sanitized;
+ const extendedName = encodeURIComponent(sanitized).replace(
+ /['()*]/g,
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+ return `attachment; filename="${asciiFallback}"${
+ needsExtended ? `; filename*=UTF-8''${extendedName}` : ""
+ }`;
+}
+
+export function assetResponseHeaders(
+ filePath: string,
+ options?: {
+ readonly download?: boolean;
+ readonly fileName?: string;
+ readonly mimeType?: string;
+ },
+): Record {
const lowerPath = filePath.toLowerCase();
return {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
- ...(lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
- ? { "Content-Type": "text/html; charset=utf-8" }
- : {}),
- ...(lowerPath.endsWith(".svg")
+ ...(options?.download
+ ? {
+ "Content-Disposition": downloadContentDisposition(options.fileName),
+ "Content-Security-Policy": "default-src 'none'; sandbox",
+ "Content-Type":
+ options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType)
+ ? options.mimeType
+ : "application/octet-stream",
+ }
+ : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
+ ? { "Content-Type": "text/html; charset=utf-8" }
+ : {}),
+ ...(!options?.download && lowerPath.endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
};
@@ -127,7 +170,10 @@ const authenticateRawRouteWithScope = (
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
- failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
+ failEnvironmentAuthInvalid(
+ EnvironmentAuth.serverAuthCredentialReason(error),
+ EnvironmentAuth.serverAuthDpopFailureReason(error),
+ ),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("internal_error", error),
@@ -238,7 +284,16 @@ export const assetRouteLayer = HttpRouter.add(
}
return yield* HttpServerResponse.file(asset.path, {
status: 200,
- headers: assetResponseHeaders(asset.path),
+ headers: assetResponseHeaders(
+ asset.path,
+ asset.download
+ ? {
+ download: true,
+ ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
+ ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
+ }
+ : undefined,
+ ),
}).pipe(
Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
);
@@ -275,15 +330,9 @@ export const attachmentUploadRouteLayer = HttpRouter.add(
});
}
- const body = yield* request.arrayBuffer.pipe(
- Effect.provideService(HttpServerRequest.MaxBodySize, FileSystem.Size(claims.sizeBytes)),
- Effect.orElseSucceed(() => null),
- );
- if (body === null) {
- return HttpServerResponse.text("Failed to read the upload body.", { status: 400 });
- }
-
- const stored = yield* storeAttachmentUpload(claims, new Uint8Array(body));
+ // Keep the request stream in the route scope until the response is sent.
+ const bodyPull = yield* Stream.toPull(request.stream);
+ const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull)));
return stored.ok
? HttpServerResponse.empty({ status: 204 })
: HttpServerResponse.text(stored.detail, { status: stored.status });
diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts
index 1f05604934..391e040f6d 100644
--- a/apps/server/src/keybindings.test.ts
+++ b/apps/server/src/keybindings.test.ts
@@ -196,6 +196,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+[");
assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]");
assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s");
+ assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p");
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");
diff --git a/apps/server/src/observability/Attributes.test.ts b/apps/server/src/observability/Attributes.test.ts
deleted file mode 100644
index d9ed2e1271..0000000000
--- a/apps/server/src/observability/Attributes.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { assert, describe, it } from "@effect/vitest";
-
-import { normalizeModelMetricLabel } from "./Attributes.ts";
-
-describe("Attributes", () => {
- it("groups GPT-family models under a shared metric label", () => {
- assert.strictEqual(normalizeModelMetricLabel("gpt-4o"), "gpt");
- assert.strictEqual(normalizeModelMetricLabel("gpt-5.4"), "gpt");
- assert.strictEqual(normalizeModelMetricLabel("claude-sonnet-4"), "claude");
- });
-});
diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
index 0b56ba2031..5ce4b70cd5 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
@@ -113,6 +113,7 @@ describe("OrchestrationEngine", () => {
detail: "historical replay should not be used during bootstrap",
}),
),
+ hasEventAfter: () => Effect.succeed(false),
};
const projectionSnapshot = {
@@ -818,6 +819,7 @@ describe("OrchestrationEngine", () => {
readAll() {
return Stream.fromIterable(events);
},
+ hasEventAfter: () => Effect.succeed(false),
};
const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
@@ -1054,6 +1056,7 @@ describe("OrchestrationEngine", () => {
readAll() {
return Stream.fromIterable(events);
},
+ hasEventAfter: () => Effect.succeed(false),
};
let shouldFailProjection = true;
diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
index 300d1526bb..b05ce3b1e2 100644
--- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
@@ -61,7 +61,7 @@ describe("OrchestrationReactor", () => {
started.push("thread-deletion-reactor");
return Effect.void;
},
- drain: Effect.void,
+ drainThrough: () => Effect.void,
}),
),
Layer.provideMerge(
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
index b15d2f679e..57215803f4 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
@@ -9,6 +9,7 @@ import {
TurnId,
ProviderInstanceId,
} from "@t3tools/contracts";
+import * as Option from "effect/Option";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
@@ -31,6 +32,7 @@ import {
OrchestrationProjectionPipelineLive,
} from "./ProjectionPipeline.ts";
import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
+import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts";
import * as ThreadPlanProgress from "../ThreadPlanProgress.ts";
import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
@@ -882,6 +884,7 @@ it.layer(
const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("Thread Revert.Files");
const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
+ const keepFileAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000004-pdf";
const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
const otherThreadAttachmentId =
"thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
@@ -983,6 +986,13 @@ it.layer(
mimeType: "image/png",
sizeBytes: 5,
},
+ {
+ type: "file",
+ id: keepFileAttachmentId,
+ name: "keep.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 5,
+ },
],
turnId: TurnId.make("turn-keep"),
streaming: false,
@@ -1045,9 +1055,11 @@ it.layer(
});
const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
+ const keepFilePath = path.join(attachmentsDir, `${keepFileAttachmentId}.pdf`);
const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
yield* fileSystem.writeFileString(keepPath, "keep");
+ yield* fileSystem.writeFileString(keepFilePath, "keep");
yield* fileSystem.writeFileString(removePath, "remove");
const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`);
yield* fileSystem.writeFileString(otherThreadPath, "other");
@@ -1072,6 +1084,7 @@ it.layer(
});
assert.isTrue(yield* exists(keepPath));
+ assert.isTrue(yield* exists(keepFilePath));
assert.isFalse(yield* exists(removePath));
assert.isTrue(yield* exists(otherThreadPath));
}),
@@ -1091,6 +1104,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("Thread Delete.Files");
const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
+ const fileAttachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000003-pdf";
const otherThreadAttachmentId =
"thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
@@ -1169,6 +1183,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
mimeType: "image/png",
sizeBytes: 5,
},
+ {
+ type: "file",
+ id: fileAttachmentId,
+ name: "delete.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 6,
+ },
],
turnId: null,
streaming: false,
@@ -1178,14 +1199,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
});
const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
+ const threadFileAttachmentPath = path.join(attachmentsDir, `${fileAttachmentId}.pdf`);
const otherThreadAttachmentPath = path.join(
attachmentsDir,
`${otherThreadAttachmentId}.png`,
);
yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
yield* fileSystem.writeFileString(threadAttachmentPath, "delete");
+ yield* fileSystem.writeFileString(threadFileAttachmentPath, "delete");
yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread");
assert.isTrue(yield* exists(threadAttachmentPath));
+ assert.isTrue(yield* exists(threadFileAttachmentPath));
assert.isTrue(yield* exists(otherThreadAttachmentPath));
yield* appendAndProject({
@@ -1205,6 +1229,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
});
assert.isFalse(yield* exists(threadAttachmentPath));
+ assert.isFalse(yield* exists(threadFileAttachmentPath));
assert.isTrue(yield* exists(otherThreadAttachmentPath));
}),
);
@@ -1254,7 +1279,183 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
},
);
+it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-replay-")))(
+ "OrchestrationProjectionPipeline",
+ (it) => {
+ it.effect("replaying a superseded thread.deleted keeps the re-created thread's files", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
+ const eventStore = yield* OrchestrationEventStore;
+ const { attachmentsDir } = yield* ServerConfig;
+ const now = "2026-01-01T00:00:00.000Z";
+ const projectId = ProjectId.make("project-replay");
+ const retriedThreadId = ThreadId.make("thread-replay-retried");
+ const goneThreadId = ThreadId.make("thread-replay-gone");
+ const retriedAttachmentPath = path.join(
+ attachmentsDir,
+ "thread-replay-retried-00000000-0000-4000-8000-000000000001.png",
+ );
+ const goneAttachmentPath = path.join(
+ attachmentsDir,
+ "thread-replay-gone-00000000-0000-4000-8000-000000000002.png",
+ );
+ const threadCreated = (threadId: ThreadId, suffix: string) =>
+ eventStore.append({
+ type: "thread.created",
+ eventId: EventId.make(`evt-replay-create-${suffix}`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: now,
+ commandId: CommandId.make(`cmd-replay-create-${suffix}`),
+ causationEventId: null,
+ correlationId: CorrelationId.make(`cmd-replay-create-${suffix}`),
+ metadata: {},
+ payload: {
+ threadId,
+ projectId,
+ title: `Thread ${suffix}`,
+ modelSelection: {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5-codex",
+ },
+ runtimeMode: "full-access",
+ branch: null,
+ worktreePath: null,
+ createdAt: now,
+ updatedAt: now,
+ },
+ });
+ const threadDeleted = (threadId: ThreadId, suffix: string) =>
+ eventStore.append({
+ type: "thread.deleted",
+ eventId: EventId.make(`evt-replay-delete-${suffix}`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ occurredAt: now,
+ commandId: CommandId.make(`cmd-replay-delete-${suffix}`),
+ causationEventId: null,
+ correlationId: CorrelationId.make(`cmd-replay-delete-${suffix}`),
+ metadata: {},
+ payload: { threadId, deletedAt: now },
+ });
+
+ yield* eventStore.append({
+ type: "project.created",
+ eventId: EventId.make("evt-replay-project"),
+ aggregateKind: "project",
+ aggregateId: projectId,
+ occurredAt: now,
+ commandId: CommandId.make("cmd-replay-project"),
+ causationEventId: null,
+ correlationId: CorrelationId.make("cmd-replay-project"),
+ metadata: {},
+ payload: {
+ projectId,
+ title: "Replay",
+ workspaceRoot: "/tmp/project-replay",
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: now,
+ updatedAt: now,
+ },
+ });
+ // A failed first send: create, roll back, then the draft retries the id.
+ yield* threadCreated(retriedThreadId, "retried-1");
+ yield* threadDeleted(retriedThreadId, "retried");
+ yield* threadCreated(retriedThreadId, "retried-2");
+ // A thread that was deleted for good.
+ yield* threadCreated(goneThreadId, "gone");
+ yield* threadDeleted(goneThreadId, "gone");
+
+ // Files on disk are not event-sourced: by the time anything replays,
+ // the retried thread's attachments already belong to its second life.
+ yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
+ yield* fileSystem.writeFileString(retriedAttachmentPath, "second incarnation");
+ yield* fileSystem.writeFileString(goneAttachmentPath, "gone");
+
+ yield* projectionPipeline.bootstrap;
+
+ assert.isTrue(yield* exists(retriedAttachmentPath));
+ assert.isFalse(yield* exists(goneAttachmentPath));
+ }),
+ );
+ },
+);
+
it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
+ it.effect("replays a bootstrap backlog larger than the event store default limit", () =>
+ Effect.gen(function* () {
+ const projectionPipeline = yield* OrchestrationProjectionPipeline;
+ const eventStore = yield* OrchestrationEventStore;
+ const sql = yield* SqlClient.SqlClient;
+ const now = "2026-01-01T00:00:00.000Z";
+ const projectId = ProjectId.make("project-bootstrap-backlog");
+
+ const sequenceRows = yield* sql<{ readonly maxSequence: number | null }>`
+ SELECT MAX(sequence) AS "maxSequence" FROM orchestration_events
+ `;
+ const sequenceBeforeBacklog = sequenceRows[0]?.maxSequence ?? 0;
+ const appendedEvents = yield* Effect.forEach(
+ Array.from({ length: 1_001 }, (_, index) => index),
+ (index) => {
+ const eventId = EventId.make(`evt-bootstrap-backlog-${index}`);
+ const commandId = CommandId.make(`cmd-bootstrap-backlog-${index}`);
+ return eventStore.append({
+ type: "project.created",
+ eventId,
+ aggregateKind: "project",
+ aggregateId: projectId,
+ occurredAt: now,
+ commandId,
+ causationEventId: null,
+ correlationId: CorrelationId.make(commandId),
+ metadata: {},
+ payload: {
+ projectId,
+ title: `Bootstrap backlog ${index}`,
+ workspaceRoot: "/tmp/project-bootstrap-backlog",
+ defaultModelSelection: null,
+ scripts: [],
+ createdAt: now,
+ updatedAt: now,
+ },
+ });
+ },
+ );
+ const lastSequence = appendedEvents[appendedEvents.length - 1]!.sequence;
+
+ yield* Effect.forEach(
+ Object.values(ORCHESTRATION_PROJECTOR_NAMES),
+ (projector) => {
+ const lastAppliedSequence =
+ projector === ORCHESTRATION_PROJECTOR_NAMES.projects
+ ? sequenceBeforeBacklog
+ : lastSequence;
+ return sql`
+ INSERT INTO projection_state (projector, last_applied_sequence, updated_at)
+ VALUES (${projector}, ${lastAppliedSequence}, ${now})
+ ON CONFLICT (projector)
+ DO UPDATE SET
+ last_applied_sequence = excluded.last_applied_sequence,
+ updated_at = excluded.updated_at
+ `;
+ },
+ { discard: true },
+ );
+
+ yield* projectionPipeline.bootstrap;
+
+ const stateRows = yield* sql<{ readonly lastAppliedSequence: number }>`
+ SELECT last_applied_sequence AS "lastAppliedSequence"
+ FROM projection_state
+ WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.projects}
+ `;
+ assert.deepEqual(stateRows, [{ lastAppliedSequence: lastSequence }]);
+ }),
+ );
+
it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
Effect.gen(function* () {
const projectionPipeline = yield* OrchestrationProjectionPipeline;
@@ -2756,7 +2957,7 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
const engineLayer = it.layer(
OrchestrationEngineLive.pipe(
- Layer.provide(OrchestrationProjectionSnapshotQueryLive),
+ Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive),
Layer.provide(ThreadBackgroundLiveness.layer),
Layer.provide(ThreadPlanProgress.layer),
Layer.provide(OrchestrationProjectionPipelineLive),
@@ -2873,4 +3074,147 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
]);
}),
);
+
+ it.effect("re-creating a deleted thread id starts from an empty projection", () =>
+ Effect.gen(function* () {
+ const engine = yield* OrchestrationEngineService;
+ const snapshotQuery = yield* ProjectionSnapshotQuery;
+ const sql = yield* SqlClient.SqlClient;
+ const createdAt = "2026-01-01T00:00:00.000Z";
+ const projectId = ProjectId.make("project-retry");
+ const threadId = ThreadId.make("thread-retry");
+ const modelSelection = {
+ instanceId: ProviderInstanceId.make("codex"),
+ model: "gpt-5-codex",
+ };
+ const createThread = (commandId: string, title: string) =>
+ engine.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(commandId),
+ threadId,
+ projectId,
+ title,
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdAt,
+ });
+ const countRowsForThread = (table: string) =>
+ sql<{ readonly count: number }>`
+ SELECT COUNT(*) AS count FROM ${sql(table)} WHERE thread_id = ${threadId}
+ `.pipe(Effect.map((rows) => rows[0]?.count ?? 0));
+ const perThreadTables = [
+ "projection_thread_messages",
+ "projection_thread_activities",
+ "projection_thread_sessions",
+ "projection_turns",
+ "projection_thread_proposed_plans",
+ "projection_pending_approvals",
+ ];
+
+ yield* engine.dispatch({
+ type: "project.create",
+ commandId: CommandId.make("cmd-retry-project"),
+ projectId,
+ title: "Retry Project",
+ workspaceRoot: "/tmp/project-retry",
+ defaultModelSelection: modelSelection,
+ createdAt,
+ });
+
+ // First attempt: the thread gets a turn, a message, an activity, and a
+ // running session before its bootstrap fails and the server rolls back.
+ yield* createThread("cmd-retry-create-1", "First attempt");
+ yield* engine.dispatch({
+ type: "thread.turn.start",
+ commandId: CommandId.make("cmd-retry-turn-1"),
+ threadId,
+ message: {
+ messageId: MessageId.make("message-retry-1"),
+ role: "user",
+ text: "first attempt",
+ attachments: [],
+ },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ createdAt,
+ });
+ yield* engine.dispatch({
+ type: "thread.activity.append",
+ commandId: CommandId.make("cmd-retry-activity-1"),
+ threadId,
+ activity: {
+ id: EventId.make("activity-retry-1"),
+ tone: "info",
+ kind: "approval.requested",
+ summary: "approval requested",
+ payload: { requestId: "request-retry-1" },
+ turnId: null,
+ createdAt,
+ },
+ createdAt,
+ });
+ yield* engine.dispatch({
+ type: "thread.proposed-plan.upsert",
+ commandId: CommandId.make("cmd-retry-plan-1"),
+ threadId,
+ proposedPlan: {
+ id: "plan-retry-1",
+ turnId: null,
+ planMarkdown: "# Plan",
+ implementedAt: null,
+ implementationThreadId: null,
+ createdAt,
+ updatedAt: createdAt,
+ },
+ createdAt,
+ });
+ yield* engine.dispatch({
+ type: "thread.session.set",
+ commandId: CommandId.make("cmd-retry-session-1"),
+ threadId,
+ session: {
+ threadId,
+ status: "running",
+ providerName: "codex",
+ runtimeMode: "full-access",
+ activeTurnId: TurnId.make("turn-retry-1"),
+ lastError: null,
+ updatedAt: createdAt,
+ },
+ createdAt,
+ });
+ for (const table of perThreadTables) {
+ assert.isAbove(yield* countRowsForThread(table), 0, `${table} should be populated`);
+ }
+ const populatedShell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId));
+ assert.isTrue(populatedShell.hasPendingApprovals);
+ assert.isTrue(populatedShell.hasActionableProposedPlan);
+
+ yield* engine.dispatch({
+ type: "thread.delete",
+ commandId: CommandId.make("cmd-retry-delete"),
+ threadId,
+ });
+ assert.isTrue(Option.isNone(yield* snapshotQuery.getThreadShellById(threadId)));
+
+ // Retry from the same draft reuses the thread id.
+ yield* createThread("cmd-retry-create-2", "Second attempt");
+
+ const shell = Option.getOrThrow(yield* snapshotQuery.getThreadShellById(threadId));
+ assert.strictEqual(shell.title, "Second attempt");
+ assert.isFalse(shell.hasPendingApprovals);
+ assert.isFalse(shell.hasActionableProposedPlan);
+ for (const table of perThreadTables) {
+ assert.strictEqual(yield* countRowsForThread(table), 0, `${table} should be empty`);
+ }
+ const detail = Option.getOrThrow(yield* snapshotQuery.getThreadDetailById(threadId));
+ assert.deepEqual(detail.messages, []);
+ assert.deepEqual(detail.activities, []);
+ assert.isNull(detail.latestTurn);
+ assert.isNull(detail.session);
+ }),
+ );
});
diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
index 15a4c77d16..3dd868abda 100644
--- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
@@ -346,14 +346,14 @@ function collectThreadAttachmentRelativePaths(
const relativePaths = new Set();
for (const message of messages) {
for (const attachment of message.attachments ?? []) {
- if (attachment.type !== "image") {
- continue;
- }
const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id);
if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
continue;
}
- relativePaths.add(attachmentRelativePath(attachment));
+ const relativePath = attachmentRelativePath(attachment);
+ if (relativePath) {
+ relativePaths.add(relativePath);
+ }
}
}
return relativePaths;
@@ -855,7 +855,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
}
case "thread.deleted": {
- attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
+ // A draft retry can re-create this id later in the log. During
+ // replay the attachment files on disk already belong to that later
+ // incarnation, so only an unsuperseded deletion removes them.
+ const recreatedLater = yield* eventStore.hasEventAfter({
+ aggregateKind: "thread",
+ aggregateId: event.payload.threadId,
+ type: "thread.created",
+ sequenceExclusive: event.sequence,
+ });
+ if (!recreatedLater) {
+ attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
+ }
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
@@ -1137,6 +1148,15 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
"applyThreadMessagesProjection",
)(function* (event, attachmentSideEffects) {
switch (event.type) {
+ // A draft retry re-creates a soft-deleted thread id. Every projector
+ // drops its own rows for the old incarnation here so replay from any
+ // per-projector cursor rebuilds the new thread without stale history.
+ case "thread.created":
+ yield* projectionThreadMessageRepository.deleteByThreadId({
+ threadId: event.payload.threadId,
+ });
+ return;
+
case "thread.message-sent": {
const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({
messageId: event.payload.messageId,
@@ -1216,6 +1236,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
"applyThreadProposedPlansProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
+ case "thread.created":
+ yield* projectionThreadProposedPlanRepository.deleteByThreadId({
+ threadId: event.payload.threadId,
+ });
+ return;
+
case "thread.proposed-plan-upserted":
yield* projectionThreadProposedPlanRepository.upsert({
planId: event.payload.proposedPlan.id,
@@ -1267,6 +1293,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
"applyThreadActivitiesProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
+ case "thread.created":
+ yield* projectionThreadActivityRepository.deleteByThreadId({
+ threadId: event.payload.threadId,
+ });
+ return;
+
case "thread.activity-appended":
yield* projectionThreadActivityRepository.upsert({
activityId: event.payload.activity.id,
@@ -1318,6 +1350,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
const applyThreadSessionsProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyThreadSessionsProjection",
)(function* (event, _attachmentSideEffects) {
+ if (event.type === "thread.created") {
+ yield* projectionThreadSessionRepository.deleteByThreadId({
+ threadId: event.payload.threadId,
+ });
+ return;
+ }
if (event.type !== "thread.session-set") {
return;
}
@@ -1337,6 +1375,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
"applyThreadTurnsProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
+ case "thread.created":
+ yield* projectionTurnRepository.deleteByThreadId({
+ threadId: event.payload.threadId,
+ });
+ return;
+
case "thread.turn-start-requested": {
yield* projectionTurnRepository.replacePendingTurnStart({
threadId: event.payload.threadId,
@@ -1674,6 +1718,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
"applyPendingApprovalsProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
+ case "thread.created":
+ yield* projectionPendingApprovalRepository.deleteByThreadId({
+ threadId: event.payload.threadId,
+ });
+ return;
+
case "thread.activity-appended": {
const requestId =
extractActivityRequestId(event.payload.activity.payload) ??
@@ -1877,6 +1927,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
Stream.runForEach(
eventStore.readFromSequence(
Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0,
+ Number.MAX_SAFE_INTEGER,
),
(event) => runProjectorForEvent(projector, event),
),
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
index 033b725fc2..a804ac0f5b 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
@@ -2986,15 +2986,15 @@ describe("ProviderCommandReactor", () => {
});
});
- it("surfaces stale provider approval request failures without faking approval resolution", async () => {
+ it("normalizes stale Codex approval callbacks without faking approval resolution", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
harness.respondToRequest.mockImplementation(() =>
Effect.fail(
new ProviderAdapterRequestError({
provider: ProviderDriverKind.make("codex"),
- method: "session/request_permission",
- detail: "Unknown pending permission request: approval-request-1",
+ method: "item/requestApproval/decision",
+ detail: "Unknown pending Codex approval request: approval-request-1",
}),
),
);
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
index e7add28529..386a42509d 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -244,13 +244,15 @@ function isUnknownPendingApprovalRequestError(cause: Cause.Cause {
const threadId = ThreadId.make("thread-deletion-reactor-test");
@@ -36,3 +61,79 @@ describe("logCleanupCauseUnlessInterrupted", () => {
}
});
});
+
+describe("ThreadDeletionReactor drain", () => {
+ const now = "2026-01-01T00:00:00.000Z";
+ const threadId = ThreadId.make("thread-deletion-reactor-drain");
+ const deletedEvent = (sequence: number): OrchestrationEvent => ({
+ sequence,
+ eventId: EventId.make(`evt-deleted-${sequence}`),
+ aggregateKind: "thread",
+ aggregateId: threadId,
+ type: "thread.deleted",
+ occurredAt: now,
+ commandId: CommandId.make(`cmd-deleted-${sequence}`),
+ causationEventId: null,
+ correlationId: CorrelationId.make(`cmd-deleted-${sequence}`),
+ metadata: {},
+ payload: { threadId, deletedAt: now },
+ });
+
+ effectIt.effect("waits for a published deletion the subscriber has not consumed yet", () =>
+ Effect.gen(function* () {
+ const stops: Array = [];
+ const firstCleanupDone = yield* Deferred.make();
+ // The engine has already committed and published sequence 2, but the
+ // subscriber has not received it yet: the stream releases it on demand.
+ const releaseSecondEvent = yield* Deferred.make();
+ const latestSequence = yield* Ref.make(0);
+ const engine = {
+ latestSequence: Ref.get(latestSequence),
+ streamDomainEvents: Stream.concat(
+ Stream.make(deletedEvent(1)),
+ Stream.fromEffect(Deferred.await(releaseSecondEvent)).pipe(
+ Stream.map(() => deletedEvent(2)),
+ ),
+ ),
+ } as unknown as OrchestrationEngineShape;
+ const providerService = {
+ stopSession: () =>
+ Effect.gen(function* () {
+ stops.push(stops.length + 1);
+ if (stops.length === 1) {
+ yield* Deferred.succeed(firstCleanupDone, undefined);
+ }
+ }),
+ } as unknown as ProviderServiceShape;
+ const terminalManager = {
+ close: () => Effect.void,
+ } as unknown as TerminalManager.TerminalManager["Service"];
+ const layer = ThreadDeletionReactorLive.pipe(
+ Layer.provide(Layer.succeed(ProviderService, providerService)),
+ Layer.provide(Layer.succeed(TerminalManager.TerminalManager, terminalManager)),
+ Layer.provide(Layer.succeed(OrchestrationEngineService, engine)),
+ );
+
+ yield* Effect.scoped(
+ Effect.gen(function* () {
+ const reactor = yield* ThreadDeletionReactor;
+ yield* reactor.start();
+ yield* Deferred.await(firstCleanupDone);
+
+ // Sequence 1 is fully cleaned and the worker queue is idle. Sequence
+ // 2 is committed and published but still in flight to the subscriber.
+ yield* Ref.set(latestSequence, 2);
+ const drained = yield* Effect.forkChild(reactor.drainThrough(2));
+ yield* Effect.yieldNow;
+ yield* Effect.yieldNow;
+ expect(stops).toEqual([1]);
+ expect(drained.pollUnsafe()).toBeUndefined();
+
+ yield* Deferred.succeed(releaseSecondEvent, undefined);
+ yield* Fiber.join(drained);
+ expect(stops).toEqual([1, 2]);
+ }),
+ ).pipe(Effect.provide(layer));
+ }),
+ );
+});
diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts
index a026f5ad81..14a92a5eae 100644
--- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts
+++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts
@@ -4,6 +4,7 @@ import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
import { ProviderService } from "../../provider/Services/ProviderService.ts";
import * as TerminalManager from "../../terminal/Manager.ts";
@@ -80,20 +81,43 @@ const make = Effect.gen(function* () {
const worker = yield* makeDrainableWorker(processThreadDeletedSafely);
+ // Highest event sequence the subscriber has handed to the worker. Waiting
+ // through a successful thread.created sequence covers every deletion that
+ // was ahead of that create in the engine queue; the worker drain then covers
+ // the in-flight cleanup.
+ const seenSequence = yield* SubscriptionRef.make(0);
+ const noteSeen = (sequence: number) =>
+ SubscriptionRef.update(seenSequence, (seen) => Math.max(seen, sequence));
+
const start: ThreadDeletionReactorShape["start"] = Effect.fn("start")(function* () {
yield* forkParked(
- Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => {
- if (event.type !== "thread.deleted") {
- return Effect.void;
- }
- return worker.enqueue(event);
- }),
+ Stream.runForEach(
+ orchestrationEngine.streamDomainEvents.pipe(
+ // Events that landed before the subscription are not replayed, so
+ // start the watermark at the current head instead of zero.
+ Stream.onStart(orchestrationEngine.latestSequence.pipe(Effect.flatMap(noteSeen))),
+ ),
+ (event) =>
+ (event.type === "thread.deleted" ? worker.enqueue(event) : Effect.void).pipe(
+ Effect.andThen(noteSeen(event.sequence)),
+ ),
+ ),
+ );
+ });
+
+ const drainThrough: ThreadDeletionReactorShape["drainThrough"] = Effect.fn(
+ "ThreadDeletionReactor.drainThrough",
+ )(function* (target) {
+ yield* SubscriptionRef.changes(seenSequence).pipe(
+ Stream.filter((seen) => seen >= target),
+ Stream.runHead,
);
+ yield* worker.drain;
});
return {
start,
- drain: worker.drain,
+ drainThrough,
} satisfies ThreadDeletionReactorShape;
});
diff --git a/apps/server/src/orchestration/Normalizer.attachments.test.ts b/apps/server/src/orchestration/Normalizer.attachments.test.ts
index 27a35977ff..7385b65315 100644
--- a/apps/server/src/orchestration/Normalizer.attachments.test.ts
+++ b/apps/server/src/orchestration/Normalizer.attachments.test.ts
@@ -93,9 +93,12 @@ describe("normalizeDispatchCommand attachments", () => {
expect(attachmentId.startsWith("thread-1-")).toBe(true);
expect(attachmentId).not.toBe(`thread-1-${attachmentUuid}`);
expect(NodeFS.existsSync(pendingPath)).toBe(true);
- expect(NodeFS.existsSync(NodePath.join(config.attachmentsDir, `${attachmentId}.png`))).toBe(
- true,
- );
+ const claimedPngPath = NodePath.join(config.attachmentsDir, `${attachmentId}.png`);
+ expect(NodeFS.existsSync(claimedPngPath)).toBe(true);
+ // A copy, not a hard link: editing the delivered file must not mutate
+ // the retryable pending upload.
+ expect(NodeFS.statSync(claimedPngPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino);
+ expect(NodeFS.readFileSync(claimedPngPath)).toEqual(bytes);
}).pipe(Effect.provide(testLayer)),
);
@@ -124,6 +127,45 @@ describe("normalizeDispatchCommand attachments", () => {
}).pipe(Effect.provide(testLayer)),
);
+ it.effect("claims uploaded documents without changing their original extension", () =>
+ Effect.gen(function* () {
+ const config = yield* ServerConfig.ServerConfig;
+ const pendingId = `pending-${attachmentUuid}-pdf`;
+ const pendingPath = NodePath.join(config.attachmentsDir, `${pendingId}.pdf`);
+ NodeFS.writeFileSync(pendingPath, Buffer.from("report"));
+
+ const imageCommand = turnStartCommand({ attachments: [] });
+ if (imageCommand.type !== "thread.turn.start") {
+ throw new Error("Expected a thread.turn.start command.");
+ }
+ const normalized = yield* normalizeDispatchCommand({
+ ...imageCommand,
+ message: {
+ ...imageCommand.message,
+ attachments: [
+ {
+ type: "file",
+ id: pendingId,
+ name: "report.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 6,
+ },
+ ],
+ },
+ });
+ if (normalized.type !== "thread.turn.start") {
+ throw new Error("Expected a thread.turn.start command.");
+ }
+
+ const attachment = normalized.message.attachments[0]!;
+ expect(attachment.type).toBe("file");
+ expect(attachment.id).toMatch(/^thread-1-.*-pdf$/);
+ const claimedPath = NodePath.join(config.attachmentsDir, `${attachment.id}.pdf`);
+ expect(NodeFS.readFileSync(claimedPath)).toEqual(Buffer.from("report"));
+ expect(NodeFS.statSync(claimedPath).ino).not.toBe(NodeFS.statSync(pendingPath).ino);
+ }).pipe(Effect.provide(testLayer)),
+ );
+
it.effect("retries a failed bootstrap with a fresh thread id", () =>
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
@@ -312,7 +354,7 @@ describe("normalizeDispatchCommand attachments", () => {
})),
},
}).pipe(Effect.flip);
- expect(mismatchedType.message).toContain("image type");
+ expect(mismatchedType.message).toContain("attachment type");
}).pipe(Effect.provide(testLayer)),
);
});
diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts
index bd6a8f242b..1226a6cd25 100644
--- a/apps/server/src/orchestration/Normalizer.ts
+++ b/apps/server/src/orchestration/Normalizer.ts
@@ -176,12 +176,14 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) =>
});
if (expectedPath !== claim.finalPath) {
return yield* new OrchestrationDispatchCommandError({
- message: `Attachment '${attachment.name}' cannot be sent: image type does not match the upload.`,
+ message: `Attachment '${attachment.name}' cannot be sent: attachment type does not match the upload.`,
});
}
// Keep the pending copy until the turn succeeds. A failed thread
- // bootstrap can then retry with a fresh thread id.
+ // bootstrap can then retry with a fresh thread id. A copy, not a
+ // hard link: an agent editing the delivered file in place must not
+ // mutate the retry source.
yield* fileSystem.copyFile(claim.currentPath, claim.finalPath).pipe(
Effect.mapError(
(cause) =>
diff --git a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts
index 7c6718965a..cdbb70919a 100644
--- a/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts
+++ b/apps/server/src/orchestration/Services/ThreadDeletionReactor.ts
@@ -23,10 +23,12 @@ export interface ThreadDeletionReactorShape {
readonly start: () => Effect.Effect;
/**
- * Resolves when the internal processing queue is empty and idle.
- * Intended for test use to replace timing-sensitive sleeps.
+ * Resolves once every thread.deleted at or before the supplied event
+ * sequence has been handed to the worker and the worker is empty and idle.
+ * A successful thread.create sequence is the fence callers use before the
+ * new incarnation can own runtime resources.
*/
- readonly drain: Effect.Effect;
+ readonly drainThrough: (sequence: number) => Effect.Effect;
}
/**
diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts
index 4a4b68ced5..b4c528480f 100644
--- a/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts
+++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
describe("ThreadBackgroundLiveness", () => {
- it("does not let status-free progress restart an idle task", () => {
+ it("does not let status-free progress or metadata restart an idle task", () => {
const liveness = ThreadBackgroundLiveness.make();
liveness.recordTaskLiveness({
threadId: "thread",
@@ -25,6 +25,36 @@ describe("ThreadBackgroundLiveness", () => {
status: undefined,
kind: "progress",
});
+ liveness.recordTaskLiveness({
+ threadId: "thread",
+ taskId: "task",
+ taskType: undefined,
+ status: undefined,
+ kind: "updated",
+ });
+ expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull();
+
+ liveness.recordTaskLiveness({
+ threadId: "thread",
+ taskId: "completed-task",
+ taskType: undefined,
+ status: undefined,
+ kind: "started",
+ });
+ liveness.recordTaskLiveness({
+ threadId: "thread",
+ taskId: "completed-task",
+ taskType: undefined,
+ status: "completed",
+ kind: "completed",
+ });
+ liveness.recordTaskLiveness({
+ threadId: "thread",
+ taskId: "completed-task",
+ taskType: undefined,
+ status: undefined,
+ kind: "updated",
+ });
expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull();
});
diff --git a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts
index d4d6da06df..2781e4981f 100644
--- a/apps/server/src/orchestration/ThreadBackgroundLiveness.ts
+++ b/apps/server/src/orchestration/ThreadBackgroundLiveness.ts
@@ -130,10 +130,9 @@ export function make(): ThreadBackgroundLivenessService["Service"] {
return;
}
- // Status-free progress is a description tick, not a restart. A delayed
- // progress event after idle must not put the task back in the live set
- // (#7128).
- if (input.kind === "progress" && input.status === undefined) {
+ // Status-free progress and metadata updates are not restarts. A delayed
+ // row after idle must not put the task back in the live set (#7128).
+ if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) {
const existing = stateByThreadId.get(input.threadId);
const stillLive =
existing !== undefined &&
diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts
index 214bfa6ae1..291d23d02b 100644
--- a/apps/server/src/orchestration/commandInvariants.test.ts
+++ b/apps/server/src/orchestration/commandInvariants.test.ts
@@ -201,4 +201,34 @@ describe("commandInvariants", () => {
),
).rejects.toThrow("already exists");
});
+
+ it("lets a draft retry re-create a thread id after its first attempt was deleted", async () => {
+ const threadId = ThreadId.make("thread-1");
+ const firstAttempt = readModel.threads.find((thread) => thread.id === threadId)!;
+ const afterRollback: OrchestrationReadModel = {
+ ...readModel,
+ threads: readModel.threads.map((thread) =>
+ thread.id === threadId ? { ...thread, deletedAt: now, updatedAt: now } : thread,
+ ),
+ };
+ const retry: OrchestrationCommand = {
+ type: "thread.create",
+ commandId: CommandId.make("cmd-retry"),
+ threadId,
+ projectId: firstAttempt.projectId,
+ title: firstAttempt.title,
+ modelSelection: firstAttempt.modelSelection,
+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+ runtimeMode: "approval-required",
+ branch: null,
+ worktreePath: null,
+ createdAt: now,
+ };
+
+ await expect(
+ Effect.runPromise(
+ requireThreadAbsent({ readModel: afterRollback, command: retry, threadId }),
+ ),
+ ).resolves.toBeUndefined();
+ });
});
diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts
index b59ded77f4..8f1e3e8985 100644
--- a/apps/server/src/orchestration/commandInvariants.ts
+++ b/apps/server/src/orchestration/commandInvariants.ts
@@ -156,7 +156,11 @@ export function requireThreadAbsent(input: {
readonly command: OrchestrationCommand;
readonly threadId: ThreadId;
}): Effect.Effect {
- if (!findThreadById(input.readModel, input.threadId)) {
+ // Thread deletion is a soft delete and a draft keeps its client-minted id
+ // across retries, so only a live row blocks creation. Projectors reset the
+ // thread's rows when the id is created again.
+ const existing = findThreadById(input.readModel, input.threadId);
+ if (existing === undefined || existing.deletedAt !== null) {
return Effect.void;
}
return Effect.fail(
diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts
index 18d0e9aa57..edd8620c3b 100644
--- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts
+++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts
@@ -15,6 +15,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as SqlSchema from "effect/unstable/sql/SqlSchema";
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 Stream from "effect/Stream";
@@ -60,6 +61,13 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({
metadata: EventMetadataFromJsonString,
});
+const HasEventAfterRequestSchema = Schema.Struct({
+ aggregateKind: Schema.String,
+ aggregateId: Schema.String,
+ type: Schema.String,
+ sequenceExclusive: NonNegativeInt,
+});
+
const ReadFromSequenceRequestSchema = Schema.Struct({
sequenceExclusive: NonNegativeInt,
limit: Schema.Number,
@@ -260,10 +268,37 @@ const makeEventStore = Effect.gen(function* () {
return readPage(sequenceExclusive, normalizedLimit);
};
+ const findEventAfter = SqlSchema.findOneOption({
+ Request: HasEventAfterRequestSchema,
+ Result: Schema.Struct({ sequence: Schema.Number }),
+ execute: (request) =>
+ sql`
+ SELECT sequence
+ FROM orchestration_events
+ WHERE aggregate_kind = ${request.aggregateKind}
+ AND stream_id = ${request.aggregateId}
+ AND event_type = ${request.type}
+ AND sequence > ${request.sequenceExclusive}
+ LIMIT 1
+ `,
+ });
+
+ const hasEventAfter: OrchestrationEventStoreShape["hasEventAfter"] = (input) =>
+ findEventAfter(input).pipe(
+ Effect.map(Option.isSome),
+ Effect.mapError(
+ toPersistenceSqlOrDecodeError(
+ "OrchestrationEventStore.hasEventAfter:query",
+ "OrchestrationEventStore.hasEventAfter:decodeRow",
+ ),
+ ),
+ );
+
return {
append,
readFromSequence,
readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER),
+ hasEventAfter,
} satisfies OrchestrationEventStoreShape;
});
diff --git a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts
index 253f6e13b9..3b159a9e17 100644
--- a/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts
+++ b/apps/server/src/persistence/Layers/ProjectionPendingApprovals.ts
@@ -95,6 +95,15 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
`,
});
+ const deleteProjectionPendingApprovalRowsByThread = SqlSchema.void({
+ Request: ListProjectionPendingApprovalsInput,
+ execute: ({ threadId }) =>
+ sql`
+ DELETE FROM projection_pending_approvals
+ WHERE thread_id = ${threadId}
+ `,
+ });
+
const upsert: ProjectionPendingApprovalRepositoryShape["upsert"] = (row) =>
upsertProjectionPendingApprovalRow(row).pipe(
Effect.mapError(toPersistenceSqlError("ProjectionPendingApprovalRepository.upsert:query")),
@@ -123,11 +132,19 @@ const makeProjectionPendingApprovalRepository = Effect.gen(function* () {
),
);
+ const deleteByThreadId: ProjectionPendingApprovalRepositoryShape["deleteByThreadId"] = (input) =>
+ deleteProjectionPendingApprovalRowsByThread(input).pipe(
+ Effect.mapError(
+ toPersistenceSqlError("ProjectionPendingApprovalRepository.deleteByThreadId:query"),
+ ),
+ );
+
return {
upsert,
listByThreadId,
getByRequestId,
deleteByRequestId,
+ deleteByThreadId,
} satisfies ProjectionPendingApprovalRepositoryShape;
});
diff --git a/apps/server/src/persistence/Services/OrchestrationEventStore.ts b/apps/server/src/persistence/Services/OrchestrationEventStore.ts
index 8b465e7713..488210ab74 100644
--- a/apps/server/src/persistence/Services/OrchestrationEventStore.ts
+++ b/apps/server/src/persistence/Services/OrchestrationEventStore.ts
@@ -52,6 +52,19 @@ export interface OrchestrationEventStoreShape {
* @returns Stream containing all stored events.
*/
readonly readAll: () => Stream.Stream;
+
+ /**
+ * Check whether an aggregate has an event of the given type after a sequence.
+ *
+ * Used during replay to tell whether a later event supersedes the one being
+ * applied, without streaming the rest of the log.
+ */
+ readonly hasEventAfter: (input: {
+ readonly aggregateKind: OrchestrationEvent["aggregateKind"];
+ readonly aggregateId: string;
+ readonly type: OrchestrationEvent["type"];
+ readonly sequenceExclusive: number;
+ }) => Effect.Effect;
}
/**
diff --git a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts
index 967e6da9d3..40b0d1ae03 100644
--- a/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts
+++ b/apps/server/src/persistence/Services/ProjectionPendingApprovals.ts
@@ -82,6 +82,13 @@ export interface ProjectionPendingApprovalRepositoryShape {
readonly deleteByRequestId: (
input: DeleteProjectionPendingApprovalInput,
) => Effect.Effect;
+
+ /**
+ * Delete every pending approval row for a thread.
+ */
+ readonly deleteByThreadId: (
+ input: ListProjectionPendingApprovalsInput,
+ ) => Effect.Effect;
}
/**
diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts
index 112f110131..0b4e957fe1 100644
--- a/apps/server/src/provider/Drivers/GrokDriver.ts
+++ b/apps/server/src/provider/Drivers/GrokDriver.ts
@@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver = {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
+ const { cwd } = yield* ServerConfig;
const eventLoggers = yield* ProviderEventLoggers;
const processEnv = mergeProviderInstanceEnvironment(environment);
const continuationIdentity = defaultProviderContinuationIdentity({
@@ -113,7 +114,7 @@ export const GrokDriver: ProviderDriver = {
});
const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
- const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe(
+ const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe(
Effect.map(stampIdentity),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts
new file mode 100644
index 0000000000..3536a37a99
--- /dev/null
+++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Sink from "effect/Sink";
+import * as Stream from "effect/Stream";
+import { ChildProcessSpawner } from "effect/unstable/process";
+
+import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts";
+
+const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills });
+
+describe("parseGrokInspectSkills", () => {
+ it("maps inspect entries onto provider skills, sorted by name", () => {
+ const skills = parseGrokInspectSkills(
+ inspectPayload([
+ {
+ name: "writing-docs",
+ description: "Write user docs.",
+ source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" },
+ userInvocable: true,
+ },
+ {
+ name: "deploy",
+ description: "Deploy the app.",
+ source: {
+ type: "plugin",
+ path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
+ },
+ userInvocable: true,
+ },
+ ]),
+ );
+
+ expect(skills).toEqual([
+ {
+ name: "deploy",
+ description: "Deploy the app.",
+ path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
+ scope: "plugin",
+ enabled: true,
+ },
+ {
+ name: "writing-docs",
+ description: "Write user docs.",
+ path: "/home/dev/.grok/skills/writing-docs/SKILL.md",
+ scope: "user",
+ enabled: true,
+ },
+ ]);
+ });
+
+ it("disables skills the CLI marks as not user-invocable", () => {
+ const skills = parseGrokInspectSkills(
+ inspectPayload([
+ {
+ name: "internal-helper",
+ source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" },
+ userInvocable: false,
+ },
+ ]),
+ );
+
+ expect(skills).toEqual([
+ {
+ name: "internal-helper",
+ path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
+ scope: "bundled",
+ enabled: false,
+ },
+ ]);
+ });
+
+ it("skips entries without a name or a filesystem path", () => {
+ const skills = parseGrokInspectSkills(
+ inspectPayload([
+ { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } },
+ { name: "no-path", source: { type: "user" } },
+ { name: "no-source" },
+ "not-an-object",
+ { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } },
+ ]),
+ );
+
+ expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
+ });
+
+ it("returns an empty list for malformed or unexpected output", () => {
+ expect(parseGrokInspectSkills("not json")).toEqual([]);
+ expect(parseGrokInspectSkills("null")).toEqual([]);
+ expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]);
+ expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]);
+ });
+});
+
+describe("discoverGrokSkills", () => {
+ it.effect("spawns the inspect probe in the configured cwd", () => {
+ const spawnCwds: Array = [];
+ const spawner = ChildProcessSpawner.make((command) => {
+ spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined);
+ return Effect.succeed(
+ ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(1),
+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
+ isRunning: Effect.succeed(false),
+ kill: () => Effect.void,
+ unref: Effect.succeed(Effect.void),
+ stdin: Sink.drain,
+ stdout: Stream.encodeText(
+ Stream.make(
+ inspectPayload([
+ {
+ name: "kept",
+ source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" },
+ },
+ ]),
+ ),
+ ),
+ stderr: Stream.empty,
+ all: Stream.empty,
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ }),
+ );
+ });
+
+ return Effect.gen(function* () {
+ const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe(
+ Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
+ );
+
+ expect(spawnCwds).toEqual(["/workspaces/demo"]);
+ expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
+ });
+ });
+});
diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts
new file mode 100644
index 0000000000..a7c2c2ae30
--- /dev/null
+++ b/apps/server/src/provider/Drivers/GrokSkills.ts
@@ -0,0 +1,119 @@
+/**
+ * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`.
+ *
+ * Unlike Claude Code, the Grok CLI reports its full skill catalog itself:
+ * `grok inspect --json` returns `skills[]` with `name`, `description`,
+ * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path`
+ * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats
+ * scanning the filesystem because the catalog honors Grok's own skill config
+ * (ignore lists, disabled skills) and includes plugin skills, which live
+ * three levels deep under `~/.grok/installed-plugins/` where a flat scan
+ * cannot see them. This mirrors how the Codex app-server reports skills over
+ * `skills/list`. Discovery is best-effort: an older CLI without `inspect`,
+ * a timeout, or malformed output yields an empty list, never a degraded
+ * provider snapshot.
+ *
+ * @module provider/Drivers/GrokSkills
+ */
+import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
+import * as Result from "effect/Result";
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
+import { resolveSpawnCommand } from "@t3tools/shared/shell";
+
+import { spawnAndCollect } from "../providerSnapshot.ts";
+
+const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;
+
+/**
+ * Map `grok inspect --json` output onto provider skills. Entries without a
+ * name or a filesystem path are skipped; `userInvocable: false` skills are
+ * kept but disabled so pickers that filter on `enabled` hide them.
+ */
+export function parseGrokInspectSkills(stdout: string): ReadonlyArray {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(stdout);
+ } catch {
+ return [];
+ }
+ if (typeof parsed !== "object" || parsed === null) {
+ return [];
+ }
+ const entries = (parsed as Record).skills;
+ if (!Array.isArray(entries)) {
+ return [];
+ }
+
+ const skillsByName = new Map();
+ for (const entry of entries) {
+ if (typeof entry !== "object" || entry === null) {
+ continue;
+ }
+ const record = entry as Record;
+ const name = typeof record.name === "string" ? record.name.trim() : "";
+ const source =
+ typeof record.source === "object" && record.source !== null
+ ? (record.source as Record)
+ : undefined;
+ const path = typeof source?.path === "string" ? source.path.trim() : "";
+ if (!name || !path) {
+ continue;
+ }
+ const scope = typeof source?.type === "string" ? source.type.trim() : "";
+ const description = typeof record.description === "string" ? record.description.trim() : "";
+ skillsByName.set(name, {
+ name,
+ path,
+ enabled: record.userInvocable !== false,
+ ...(scope ? { scope } : {}),
+ ...(description ? { description } : {}),
+ });
+ }
+
+ return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
+}
+
+/**
+ * Run `grok inspect --json` and map the reported catalog onto provider
+ * skills. Never fails: any spawn error, non-zero exit, or timeout resolves
+ * to an empty list.
+ */
+export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (
+ grokSettings: Pick,
+ environment: NodeJS.ProcessEnv = process.env,
+ cwd?: string,
+): Effect.fn.Return<
+ ReadonlyArray,
+ never,
+ ChildProcessSpawner.ChildProcessSpawner
+> {
+ const command = grokSettings.binaryPath || "grok";
+ const inspectResult = yield* Effect.gen(function* () {
+ const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], {
+ env: environment,
+ });
+ return yield* spawnAndCollect(
+ command,
+ ChildProcess.make(spawnCommand.command, spawnCommand.args, {
+ ...(cwd ? { cwd } : {}),
+ env: environment,
+ shell: spawnCommand.shell,
+ }),
+ );
+ }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result);
+
+ if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) {
+ yield* Effect.logDebug("Grok skill discovery failed; continuing without skills.");
+ return [];
+ }
+ const output = inspectResult.success.value;
+ if (output.code !== 0) {
+ yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", {
+ exitCode: output.code,
+ });
+ return [];
+ }
+ return parseGrokInspectSkills(output.stdout);
+});
diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts
index a01e414f81..58ccf59120 100644
--- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts
+++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts
@@ -34,6 +34,7 @@ import {
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import { OpenCodeRuntime } from "../opencodeRuntime.ts";
+import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts";
import {
defaultProviderContinuationIdentity,
type ProviderDriver,
@@ -141,13 +142,27 @@ export const OpenCodeDriver: ProviderDriver
environment: processEnv,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
});
- const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv);
+ const serverOwner = yield* OpenCodeServerOwner.make({
+ binaryPath: effectiveConfig.binaryPath,
+ directory: serverConfig.cwd,
+ ...(effectiveConfig.serverPassword
+ ? { serverPassword: effectiveConfig.serverPassword }
+ : {}),
+ environment: processEnv,
+ });
+ const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig).pipe(
+ Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
+ );
const checkProvider = checkOpenCodeProviderStatus(
effectiveConfig,
serverConfig.cwd,
processEnv,
- ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime));
+ ).pipe(
+ Effect.map(stampIdentity),
+ Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
+ Effect.provideService(OpenCodeRuntime, openCodeRuntime),
+ );
const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider>(
@@ -156,6 +171,8 @@ export const OpenCodeDriver: ProviderDriver
getSettings: snapshotSettings.getSettings,
streamSettings: snapshotSettings.streamSettings,
haveSettingsChanged: haveProviderSnapshotSettingsChanged,
+ checkProviderOnSettingsChange: () => false,
+ refreshOnInterval: false,
initialSnapshot: (settings) =>
makePendingOpenCodeProvider(settings.provider).pipe(Effect.map(stampIdentity)),
checkProvider,
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
index 63d13b8fdc..c7fef47501 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts
@@ -808,7 +808,7 @@ describe("ClaudeAdapterLive", () => {
mimeType: "image/png",
sizeBytes: 4,
};
- const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment));
+ const attachmentPath = NodePath.join(attachmentsDir, attachmentRelativePath(attachment)!);
NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true });
NodeFS.writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));
diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts
index c13b53e32d..7061b0aa6f 100644
--- a/apps/server/src/provider/Layers/ClaudeAdapter.ts
+++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts
@@ -1307,6 +1307,8 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
}
for (const attachment of input.attachments ?? []) {
+ // Claude ingests images only. Generic files reach the agent through the
+ // path line ProviderService puts in the prompt.
if (attachment.type !== "image") {
continue;
}
diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts
index a3ecd6ac64..c3ddfc475e 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.test.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts
@@ -568,6 +568,89 @@ function startLifecycleRuntime() {
}
lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
+ it.effect("carries child model metadata through every task event", () =>
+ Effect.gen(function* () {
+ const { adapter, runtime } = yield* startLifecycleRuntime();
+ const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 10)).pipe(
+ Effect.forkChild,
+ );
+
+ const cases = [
+ ["collabAgent/started", {}],
+ ["collabAgent/activity", { activityKind: "started" }],
+ ["collabAgent/turnStarted", {}],
+ ["collabAgent/turnCompleted", { turn: { status: "completed" } }],
+ ["collabAgent/statusChanged", { status: { type: "active", activeFlags: [] } }],
+ ["collabAgent/tokenUsage", { tokenUsage: { total: { totalTokens: 42 } } }],
+ ["collabAgent/item", { item: { type: "commandExecution", command: "pwd" } }],
+ ["collabAgent/closed", {}],
+ ["collabAgent/metadataUpdated", {}],
+ ] as const;
+
+ for (const [index, [method, extra]] of cases.entries()) {
+ yield* runtime.emit({
+ id: asEventId(`evt-child-model-${index}`),
+ kind: "notification",
+ provider: ProviderDriverKind.make("codex"),
+ createdAt: "2026-01-01T00:00:00.000Z",
+ method,
+ threadId: asThreadId("thread-1"),
+ turnId: asTurnId("turn-1"),
+ payload: {
+ agentThreadId: "child-model",
+ agentPath: "/root/model-check",
+ model: " gpt-5.6-sol ",
+ effort: " high ",
+ ...extra,
+ },
+ });
+ }
+ yield* runtime.emit({
+ id: asEventId("evt-child-model-blank"),
+ kind: "notification",
+ provider: ProviderDriverKind.make("codex"),
+ createdAt: "2026-01-01T00:00:00.000Z",
+ method: "collabAgent/metadataUpdated",
+ threadId: asThreadId("thread-1"),
+ turnId: asTurnId("turn-1"),
+ payload: {
+ agentThreadId: "child-model",
+ model: " ",
+ effort: "",
+ },
+ });
+
+ const events = Array.from(yield* Fiber.join(eventsFiber));
+ NodeAssert.deepStrictEqual(
+ events.map((event) => event.type),
+ [
+ "task.started",
+ "task.started",
+ "task.updated",
+ "task.updated",
+ "task.updated",
+ "task.progress",
+ "task.progress",
+ "task.updated",
+ "task.updated",
+ "task.updated",
+ ],
+ );
+ for (const event of events.slice(0, -1)) {
+ const payload = event.payload as Record;
+ NodeAssert.equal(payload.model, "gpt-5.6-sol");
+ NodeAssert.equal(payload.effort, "high");
+ }
+
+ const metadataPayload = events[8]?.payload as Record;
+ NodeAssert.equal("status" in metadataPayload, false);
+ const blankMetadataPayload = events[9]?.payload as Record;
+ NodeAssert.equal("status" in blankMetadataPayload, false);
+ NodeAssert.equal("model" in blankMetadataPayload, false);
+ NodeAssert.equal("effort" in blankMetadataPayload, false);
+ }),
+ );
+
it.effect("does not reactivate an idle child after a parent interaction", () =>
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 e2a5898a75..b8c488cb67 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.ts
@@ -541,12 +541,16 @@ function mapCollabAgentEvent(
// finding: progress rows renamed math_one to its UUID).
const knownName = nickname ?? pathLeaf;
const title = knownName ?? agentThreadId;
+ const model = typeof payload.model === "string" ? payload.model.trim() : "";
+ const effort = typeof payload.effort === "string" ? payload.effort.trim() : "";
// Identity repeated on every status patch so rows are self-describing when
// the start row ages out of activity retention (review finding: a
// reconstructed agent had a UUID name and no role/path).
- const statusLinkage = {
+ const linkage = {
role,
...(knownName ? { title: knownName } : {}),
+ ...(model ? { model } : {}),
+ ...(effort ? { effort } : {}),
...(agentPath ? { agentPath } : {}),
timelineBypass: true,
} as const;
@@ -561,15 +565,21 @@ function mapCollabAgentEvent(
taskId,
description: title,
title,
- role,
- ...(agentPath ? { agentPath } : {}),
+ ...linkage,
...(typeof payload.parentThreadId === "string"
? { parentAgentId: payload.parentThreadId }
: {}),
- timelineBypass: true,
},
},
];
+ case "collabAgent/metadataUpdated":
+ return [
+ {
+ ...base,
+ type: "task.updated",
+ payload: { taskId, ...linkage },
+ },
+ ];
case "collabAgent/activity": {
const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : "";
if (activityKind === "interrupted") {
@@ -577,7 +587,7 @@ function mapCollabAgentEvent(
{
...base,
type: "task.updated",
- payload: { taskId, status: "interrupted", ...statusLinkage },
+ payload: { taskId, status: "interrupted", ...linkage },
},
];
}
@@ -594,9 +604,7 @@ function mapCollabAgentEvent(
taskId,
description: title,
title,
- role,
- ...(agentPath ? { agentPath } : {}),
- timelineBypass: true,
+ ...linkage,
},
},
];
@@ -610,7 +618,7 @@ function mapCollabAgentEvent(
{
...base,
type: "task.updated",
- payload: { taskId, status: "running", ...statusLinkage },
+ payload: { taskId, status: "running", ...linkage },
},
];
case "collabAgent/turnCompleted": {
@@ -630,7 +638,7 @@ function mapCollabAgentEvent(
{
...base,
type: "task.updated",
- payload: { taskId, status, ...statusLinkage },
+ payload: { taskId, status, ...linkage },
},
];
}
@@ -646,7 +654,7 @@ function mapCollabAgentEvent(
{
...base,
type: "task.updated",
- payload: { taskId, status: "failed", ...statusLinkage },
+ payload: { taskId, status: "failed", ...linkage },
},
];
}
@@ -659,7 +667,7 @@ function mapCollabAgentEvent(
{
...base,
type: "task.updated",
- payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage },
+ payload: { taskId, status: waiting ? "waiting" : "running", ...linkage },
},
];
}
@@ -668,7 +676,7 @@ function mapCollabAgentEvent(
{
...base,
type: "task.updated",
- payload: { taskId, status: "idle", ...statusLinkage },
+ payload: { taskId, status: "idle", ...linkage },
},
];
}
@@ -715,9 +723,8 @@ function mapCollabAgentEvent(
payload: {
taskId,
description: title,
- ...(knownName ? { title: knownName } : {}),
+ ...linkage,
typedUsage,
- timelineBypass: true,
},
},
];
@@ -747,9 +754,8 @@ function mapCollabAgentEvent(
payload: {
taskId,
description: title,
- ...(knownName ? { title: knownName } : {}),
+ ...linkage,
summary,
- timelineBypass: true,
},
},
];
@@ -759,7 +765,7 @@ function mapCollabAgentEvent(
{
...base,
type: "task.updated",
- payload: { taskId, status: "interrupted", ...statusLinkage },
+ payload: { taskId, status: "interrupted", ...linkage },
},
];
default:
@@ -1863,8 +1869,11 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
});
const sendTurn: CodexAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) {
+ // Codex ingests images only. Anything else would be base64-encoded as an
+ // image and rejected or misread; generic files reach the agent through the
+ // path line ProviderService puts in the prompt.
const codexAttachments = yield* Effect.forEach(
- input.attachments ?? [],
+ (input.attachments ?? []).filter((attachment) => attachment.type === "image"),
(attachment) => resolveAttachment(input, attachment),
{ concurrency: 1 },
);
diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts
index 5af06efb71..5bc5940fe5 100644
--- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts
+++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts
@@ -81,10 +81,319 @@ function buildScript() {
};
}
+function capturedStartedActivity(childId = CHILD_A) {
+ const captured = wireFixture.notifications.find((entry) => {
+ const item = (entry.params as { item?: { type?: string; kind?: string } }).item;
+ return item?.type === "subAgentActivity" && item.kind === "started";
+ });
+ assert.isDefined(captured);
+ return {
+ ...captured,
+ params: {
+ ...captured.params,
+ item: {
+ ...captured.params.item,
+ agentThreadId: childId,
+ agentPath: "/root/model-check",
+ },
+ },
+ };
+}
+
+function capturedSpawnedThread(childId = CHILD_A) {
+ const captured = wireFixture.notifications.find((entry) => entry.method === "thread/started");
+ assert.isDefined(captured);
+ return {
+ ...captured,
+ params: {
+ thread: {
+ ...captured.params.thread,
+ id: childId,
+ sessionId: childId,
+ parentThreadId: ROOT,
+ agentNickname: "model-check",
+ agentRole: "verifier",
+ source: {
+ subAgent: {
+ thread_spawn: {
+ agent_nickname: "model-check",
+ agent_path: "/root/model-check",
+ agent_role: "verifier",
+ depth: 1,
+ parent_thread_id: ROOT,
+ },
+ },
+ },
+ },
+ },
+ };
+}
+
+function childSettings(threadId: string, model: string, effort: string) {
+ return {
+ method: "thread/settings/updated",
+ params: {
+ threadId,
+ threadSettings: {
+ approvalPolicy: "on-request",
+ approvalsReviewer: "auto_review",
+ collaborationMode: { mode: "default", settings: { model } },
+ cwd: "/workspace/repo",
+ effort,
+ model,
+ modelProvider: "openai",
+ sandboxPolicy: { type: "dangerFullAccess" },
+ },
+ },
+ };
+}
+
+function readRecordedRequests() {
+ return NodeFS.readFileSync(`${scriptPath}.requests`, "utf8")
+ .trim()
+ .split("\n")
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line) as { method: string; params: Record });
+}
+
const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json");
const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh");
describe("CodexSessionRuntime collab integration", () => {
+ it.effect("looks up child model metadata once after activity registration", () =>
+ Effect.gen(function* () {
+ const script = {
+ rootThreadId: ROOT,
+ recordRequests: true,
+ notifications: [
+ capturedStartedActivity(),
+ capturedStartedActivity(),
+ {
+ ...capturedStartedActivity(CHILD_B),
+ params: {
+ ...capturedStartedActivity(CHILD_B).params,
+ item: { ...capturedStartedActivity(CHILD_B).params.item, kind: "interacted" },
+ },
+ },
+ { method: "thread/closed", params: { threadId: CHILD_B } },
+ capturedSpawnedThread(ROOT),
+ ],
+ childResumeSnapshots: {
+ [CHILD_A]: { model: "gpt-5.6-luna", reasoningEffort: "low" },
+ },
+ };
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
+ NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ NodeFS.rmSync(scriptPath, { force: true });
+ NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
+ }),
+ );
+
+ const runtime = yield* makeCodexSessionRuntime({
+ threadId: ThreadId.make("thread-collab-model-activity"),
+ binaryPath: peerPath,
+ cwd: "/tmp",
+ runtimeMode: "full-access",
+ environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
+ });
+ const metadataFiber = yield* runtime.events.pipe(
+ Stream.filter(
+ (event) =>
+ event.method === "collabAgent/metadataUpdated" &&
+ (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A,
+ ),
+ Stream.take(1),
+ Stream.runCollect,
+ Effect.forkScoped,
+ );
+
+ const session = yield* runtime.start();
+ assert.equal(session.model, "gpt-5.6-sol");
+ yield* runtime.sendTurn({ input: "start one child" });
+ const metadataEvents = Array.from(yield* Fiber.join(metadataFiber));
+ assert.deepInclude(metadataEvents[0]?.payload, {
+ agentThreadId: CHILD_A,
+ model: "gpt-5.6-luna",
+ effort: "low",
+ });
+ assert.deepEqual(readRecordedRequests(), [
+ {
+ method: "thread/resume",
+ params: { threadId: CHILD_A, excludeTurns: true },
+ },
+ ]);
+
+ yield* runtime.close;
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("keeps child settings and reroutes newer than the resume snapshot", () =>
+ Effect.gen(function* () {
+ const statusChanged = wireFixture.notifications.find(
+ (entry) =>
+ entry.method === "thread/status/changed" &&
+ (entry.params as { threadId?: string }).threadId === CHILD_A,
+ );
+ assert.isDefined(statusChanged);
+ const script = {
+ rootThreadId: ROOT,
+ recordRequests: true,
+ notifications: [
+ childSettings(CHILD_A, "child-before", "medium"),
+ capturedSpawnedThread(),
+ childSettings(CHILD_A, "child-after", "high"),
+ {
+ method: "model/rerouted",
+ params: {
+ threadId: CHILD_A,
+ turnId: `${CHILD_A}-turn`,
+ fromModel: "child-after",
+ toModel: "child-rerouted",
+ reason: "highRiskCyberActivity",
+ },
+ },
+ {
+ method: "model/rerouted",
+ params: {
+ threadId: ROOT,
+ turnId: `${ROOT}-turn`,
+ fromModel: "gpt-5.6-sol",
+ toModel: "root-rerouted",
+ reason: "highRiskCyberActivity",
+ },
+ },
+ ],
+ childResumeSnapshots: {
+ [CHILD_A]: {
+ model: "stale-snapshot",
+ reasoningEffort: "low",
+ notifications: [statusChanged],
+ },
+ },
+ };
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
+ NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ NodeFS.rmSync(scriptPath, { force: true });
+ NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
+ }),
+ );
+
+ const runtime = yield* makeCodexSessionRuntime({
+ threadId: ThreadId.make("thread-collab-model-spawn"),
+ binaryPath: peerPath,
+ cwd: "/tmp",
+ runtimeMode: "full-access",
+ environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
+ });
+ const eventsFiber = yield* runtime.events.pipe(
+ Stream.takeUntil(
+ (event) =>
+ event.method === "collabAgent/statusChanged" &&
+ (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A,
+ ),
+ Stream.runCollect,
+ Effect.forkScoped,
+ );
+
+ yield* runtime.start();
+ yield* runtime.sendTurn({ input: "start one spawned child" });
+ const events = Array.from(yield* Fiber.join(eventsFiber));
+ const started = events.find((event) => event.method === "collabAgent/started");
+ assert.deepInclude(started?.payload, {
+ agentThreadId: CHILD_A,
+ model: "child-before",
+ effort: "medium",
+ });
+ const childStatus = events.find((event) => event.method === "collabAgent/statusChanged");
+ assert.deepInclude(childStatus?.payload, {
+ agentThreadId: CHILD_A,
+ model: "child-rerouted",
+ effort: "high",
+ });
+ assert.isTrue(
+ events.some(
+ (event) =>
+ event.method === "model/rerouted" &&
+ (event.payload as { threadId?: string }).threadId === ROOT,
+ ),
+ "the root reroute must stay on the parent path",
+ );
+ assert.isFalse(
+ events.some(
+ (event) =>
+ (event.method === "thread/settings/updated" || event.method === "model/rerouted") &&
+ (event.payload as { threadId?: string }).threadId === CHILD_A,
+ ),
+ "child metadata notifications must not leak to the parent path",
+ );
+ assert.equal(readRecordedRequests().length, 1);
+
+ yield* runtime.close;
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.effect("does not delay the parent turn when the child lookup fails", () =>
+ Effect.gen(function* () {
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => {
+ NodeFS.rmSync(scriptPath, { force: true });
+ NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
+ }),
+ );
+ for (const [name, childSnapshot] of [
+ ["hang", { hang: true }],
+ ["error", { error: "child unavailable" }],
+ ] as const) {
+ yield* Effect.gen(function* () {
+ const marker = `lookup-${name}`;
+ const script = {
+ rootThreadId: ROOT,
+ recordRequests: true,
+ resumeRequestMarker: marker,
+ notifications: [capturedStartedActivity()],
+ childResumeSnapshots: { [CHILD_A]: childSnapshot },
+ };
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
+ NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
+
+ const runtime = yield* makeCodexSessionRuntime({
+ threadId: ThreadId.make(`thread-collab-model-${name}`),
+ binaryPath: peerPath,
+ cwd: "/tmp",
+ runtimeMode: "full-access",
+ environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
+ });
+ const eventsFiber = yield* runtime.events.pipe(
+ Stream.takeUntil(
+ (event) =>
+ event.method === "serverRequest/resolved" &&
+ (event.payload as { requestId?: string }).requestId === marker,
+ ),
+ Stream.runCollect,
+ Effect.forkScoped,
+ );
+
+ yield* runtime.start();
+ yield* runtime.sendTurn({ input: "finish without child metadata" });
+ const events = Array.from(yield* Fiber.join(eventsFiber));
+ assert.isTrue(events.some((event) => event.method === "turn/completed"));
+ assert.equal(readRecordedRequests().length, 1);
+
+ yield* runtime.close;
+ NodeFS.rmSync(scriptPath, { force: true });
+ NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
+ }).pipe(Effect.scoped);
+ }
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
it.effect("replays the captured fan-out into synthetic agent events without child leaks", () =>
Effect.gen(function* () {
// @effect-diagnostics-next-line preferSchemaOverJson:off
diff --git a/apps/server/src/provider/Layers/CodexCollabWire.test.ts b/apps/server/src/provider/Layers/CodexCollabWire.test.ts
index 50e5e819d1..363c1560ca 100644
--- a/apps/server/src/provider/Layers/CodexCollabWire.test.ts
+++ b/apps/server/src/provider/Layers/CodexCollabWire.test.ts
@@ -119,6 +119,8 @@ describe("routeCodexChildNotification", () => {
"turn/completed",
"thread/status/changed",
"thread/tokenUsage/updated",
+ "thread/settings/updated",
+ "model/rerouted",
"item/started",
"item/completed",
"thread/closed",
@@ -159,6 +161,8 @@ describe("routeCodexChildNotification", () => {
"turn/completed",
"turn/plan/updated",
"item/plan/delta",
+ "thread/settings/updated",
+ "model/rerouted",
]) {
assert.notEqual(
routeCodexChildNotification(method),
diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts
index 52a8fdd25d..776a8e00e1 100644
--- a/apps/server/src/provider/Layers/CodexProvider.ts
+++ b/apps/server/src/provider/Layers/CodexProvider.ts
@@ -86,13 +86,18 @@ function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["accoun
return "ChatGPT Pro 5x Subscription";
case "team":
return "ChatGPT Team Subscription";
+ case "self_serve_business_prolite":
case "self_serve_business_usage_based":
case "business":
return "ChatGPT Business Subscription";
+ case "ent26":
+ case "enterprise_cbp_automation":
case "enterprise_cbp_usage_based":
case "enterprise":
return "ChatGPT Enterprise Subscription";
case "edu":
+ case "edu_plus":
+ case "edu_pro":
return "ChatGPT Edu Subscription";
case "unknown":
return "ChatGPT Subscription";
diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts
index bba888ee3a..85f44065ed 100644
--- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts
+++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts
@@ -146,6 +146,12 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP
const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect(
CodexTurnStartParamsWithCollaborationMode,
);
+const CodexChildResumeMetadata = Schema.Struct({
+ thread: Schema.Struct({ id: Schema.String }),
+ model: Schema.String,
+ reasoningEffort: Schema.optionalKey(Schema.NullOr(Schema.String)),
+});
+const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata);
export type CodexTurnStartParamsWithCollaborationMode =
typeof CodexTurnStartParamsWithCollaborationMode.Type;
@@ -743,8 +749,10 @@ function readNotificationThreadId(notification: CodexServerNotification): string
case "thread/unarchived":
case "thread/closed":
case "thread/name/updated":
+ case "thread/settings/updated":
case "thread/tokenUsage/updated":
case "thread/goal/cleared":
+ case "model/rerouted":
case "turn/started":
case "hook/started":
case "turn/completed":
@@ -919,6 +927,35 @@ interface CollabChildAgentState {
readonly spawnTurnId: TurnId | undefined;
}
+interface CollabChildMetadataState {
+ readonly model: string | undefined;
+ readonly effort: string | undefined;
+ readonly lookupStarted: boolean;
+ readonly closed: boolean;
+}
+
+function collabChildIdentity(
+ child: CollabChildAgentState,
+ metadata: CollabChildMetadataState | undefined,
+) {
+ return {
+ agentThreadId: child.agentThreadId,
+ ...(child.nickname ? { nickname: child.nickname } : {}),
+ ...(child.role ? { role: child.role } : {}),
+ ...(child.agentPath ? { agentPath: child.agentPath } : {}),
+ ...(metadata?.model ? { model: metadata.model } : {}),
+ ...(metadata?.effort ? { effort: metadata.effort } : {}),
+ };
+}
+
+function nonEmptyMetadataValue(value: unknown): string | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+}
+
function readThreadSpawnSource(thread: { readonly source: unknown }):
| {
nickname: string | undefined;
@@ -984,9 +1021,11 @@ function shouldSuppressChildConversationNotification(
method === "thread/closed" ||
method === "thread/compacted" ||
method === "thread/name/updated" ||
+ method === "thread/settings/updated" ||
method === "thread/tokenUsage/updated" ||
method === "thread/goal/updated" ||
method === "thread/goal/cleared" ||
+ method === "model/rerouted" ||
method === "turn/started" ||
method === "turn/completed" ||
method === "turn/plan/updated" ||
@@ -1017,6 +1056,8 @@ const CHILD_AGENT_EVENT_METHODS: ReadonlySet = new Set([
"turn/completed",
"thread/status/changed",
"thread/tokenUsage/updated",
+ "thread/settings/updated",
+ "model/rerouted",
"item/started",
"item/completed",
"thread/closed",
@@ -1035,7 +1076,6 @@ const CHILD_CHATTER_METHODS: ReadonlySet = new Set([
"turn/plan/updated",
"turn/diff/updated",
"thread/name/updated",
- "thread/settings/updated",
"rawResponseItem/completed",
// Child-owned thread lifecycle: the parent adapter maps these onto the
// PARENT thread (archived/compacted state), so a child compacting would
@@ -1143,6 +1183,7 @@ export const makeCodexSessionRuntime = (
const pendingUserInputsRef = yield* Ref.make(new Map());
const collabReceiverTurnsRef = yield* Ref.make(new Map());
const collabChildAgentsRef = yield* Ref.make(new Map());
+ const collabChildMetadataRef = yield* Ref.make(new Map());
/** Child provider-thread id → its currently running provider turn id. */
const collabChildLiveTurnsRef = yield* Ref.make(new Map());
const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter();
@@ -1238,6 +1279,133 @@ export const makeCodexSessionRuntime = (
message,
});
+ const updateCollabChildMetadata = (
+ agentThreadId: string,
+ update: { readonly model?: string; readonly effort?: string },
+ overwriteKnown: boolean,
+ ) =>
+ Ref.modify(collabChildMetadataRef, (current) => {
+ const previous = current.get(agentThreadId) ?? {
+ model: undefined,
+ effort: undefined,
+ lookupStarted: false,
+ closed: false,
+ };
+ const model =
+ update.model && (overwriteKnown || !previous.model) ? update.model : previous.model;
+ const effort =
+ update.effort && (overwriteKnown || !previous.effort) ? update.effort : previous.effort;
+ const changed = model !== previous.model || effort !== previous.effort;
+ if (!changed) {
+ return [false, current] as const;
+ }
+ const next = new Map(current);
+ next.set(agentThreadId, { ...previous, model, effort });
+ return [true, next] as const;
+ });
+
+ const markCollabChildClosed = (agentThreadId: string) =>
+ Ref.update(collabChildMetadataRef, (current) => {
+ const previous = current.get(agentThreadId) ?? {
+ model: undefined,
+ effort: undefined,
+ lookupStarted: false,
+ closed: false,
+ };
+ if (previous.closed) {
+ return current;
+ }
+ const next = new Map(current);
+ next.set(agentThreadId, { ...previous, closed: true });
+ return next;
+ });
+
+ const markCollabChildOpen = (agentThreadId: string) =>
+ Ref.update(collabChildMetadataRef, (current) => {
+ const previous = current.get(agentThreadId);
+ if (!previous?.closed) {
+ return current;
+ }
+ const next = new Map(current);
+ next.set(agentThreadId, { ...previous, closed: false });
+ return next;
+ });
+
+ const emitCollabChildMetadataUpdated = Effect.fn(
+ "CodexSessionRuntime.emitCollabChildMetadataUpdated",
+ )(function* (agentThreadId: string) {
+ const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId);
+ const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId);
+ if (!child || metadata?.closed) {
+ return;
+ }
+ yield* emitEvent({
+ kind: "notification",
+ threadId: options.threadId,
+ ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
+ method: "collabAgent/metadataUpdated",
+ payload: collabChildIdentity(child, metadata),
+ });
+ });
+
+ const startCollabChildMetadataLookup = Effect.fn(
+ "CodexSessionRuntime.startCollabChildMetadataLookup",
+ )(function* (agentThreadId: string) {
+ const shouldStart = yield* Ref.modify(collabChildMetadataRef, (current) => {
+ const previous = current.get(agentThreadId) ?? {
+ model: undefined,
+ effort: undefined,
+ lookupStarted: false,
+ closed: false,
+ };
+ if (previous.lookupStarted || previous.closed) {
+ return [false, current] as const;
+ }
+ const next = new Map(current);
+ next.set(agentThreadId, { ...previous, lookupStarted: true });
+ return [true, next] as const;
+ });
+ if (!shouldStart) {
+ return;
+ }
+
+ // The child is already loaded. This rejoins it without starting a turn,
+ // and excludeTurns avoids loading or replaying its history.
+ yield* client.raw
+ .request("thread/resume", { threadId: agentThreadId, excludeTurns: true })
+ .pipe(
+ Effect.flatMap(decodeCodexChildResumeMetadata),
+ Effect.timeout("5 seconds"),
+ Effect.flatMap((response) =>
+ Effect.gen(function* () {
+ if (response.thread.id !== agentThreadId) {
+ return;
+ }
+ const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId);
+ const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId);
+ if (!child || metadata?.closed) {
+ return;
+ }
+ const model = nonEmptyMetadataValue(response.model);
+ const effort = nonEmptyMetadataValue(response.reasoningEffort);
+ const changed = yield* updateCollabChildMetadata(
+ agentThreadId,
+ {
+ ...(model ? { model } : {}),
+ ...(effort ? { effort } : {}),
+ },
+ false,
+ );
+ if (changed) {
+ yield* emitCollabChildMetadataUpdated(agentThreadId);
+ }
+ }),
+ ),
+ Effect.catch(() => Effect.void),
+ Effect.forkIn(runtimeScope),
+ );
+ });
+
const settlePendingApprovals = (decision: ProviderApprovalDecision) =>
Ref.get(pendingApprovalsRef).pipe(
Effect.flatMap((pendingApprovals) =>
@@ -1278,6 +1446,10 @@ export const makeCodexSessionRuntime = (
if (!spawn) {
return false;
}
+ const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef));
+ if (thread.id === rootProviderThreadId) {
+ return false;
+ }
// Merge with any subAgentActivity registration that got here
// first. spawnTurnId is REGISTRATION-time-only on both paths: for
// an already-known child we keep its value (set or unset) — a
@@ -1304,20 +1476,19 @@ export const makeCodexSessionRuntime = (
next.set(thread.id, state);
return next;
});
+ const metadata = (yield* Ref.get(collabChildMetadataRef)).get(thread.id);
yield* emitEvent({
kind: "notification",
threadId: options.threadId,
method: "collabAgent/started",
...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}),
payload: {
- agentThreadId: state.agentThreadId,
- ...(state.nickname ? { nickname: state.nickname } : {}),
- ...(state.role ? { role: state.role } : {}),
- ...(state.agentPath ? { agentPath: state.agentPath } : {}),
+ ...collabChildIdentity(state, metadata),
...(state.depth !== undefined ? { depth: state.depth } : {}),
...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}),
},
});
+ yield* startCollabChildMetadataLookup(thread.id);
return true;
}
@@ -1367,17 +1538,22 @@ export const makeCodexSessionRuntime = (
return next;
});
const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId);
+ const metadata = (yield* Ref.get(collabChildMetadataRef)).get(item.agentThreadId);
yield* emitEvent({
kind: "notification",
threadId: options.threadId,
method: "collabAgent/activity",
...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}),
payload: {
- agentThreadId: item.agentThreadId,
- agentPath: item.agentPath,
+ ...(registeredChild
+ ? collabChildIdentity(registeredChild, metadata)
+ : { agentThreadId: item.agentThreadId, agentPath: item.agentPath }),
activityKind: item.kind,
},
});
+ if (item.kind === "started") {
+ yield* startCollabChildMetadataLookup(item.agentThreadId);
+ }
return true;
}
@@ -1393,19 +1569,45 @@ export const makeCodexSessionRuntime = (
if (providerConversationId === interceptRootId) {
return false;
}
+
+ if (
+ interceptRootId !== undefined &&
+ (notification.method === "thread/settings/updated" ||
+ notification.method === "model/rerouted")
+ ) {
+ const model = nonEmptyMetadataValue(
+ notification.method === "thread/settings/updated"
+ ? notification.params.threadSettings.model
+ : notification.params.toModel,
+ );
+ const effort =
+ notification.method === "thread/settings/updated"
+ ? nonEmptyMetadataValue(notification.params.threadSettings.effort)
+ : undefined;
+ const changed = yield* updateCollabChildMetadata(
+ providerConversationId,
+ {
+ ...(model ? { model } : {}),
+ ...(effort ? { effort } : {}),
+ },
+ true,
+ );
+ if (changed && (yield* Ref.get(collabChildAgentsRef)).has(providerConversationId)) {
+ yield* emitCollabChildMetadataUpdated(providerConversationId);
+ }
+ return true;
+ }
+
const children = yield* Ref.get(collabChildAgentsRef);
const child = children.get(providerConversationId);
if (!child) {
return false;
}
- const childIdentity = {
- agentThreadId: child.agentThreadId,
- ...(child.nickname ? { nickname: child.nickname } : {}),
- ...(child.role ? { role: child.role } : {}),
- ...(child.agentPath ? { agentPath: child.agentPath } : {}),
- };
+ const metadata = (yield* Ref.get(collabChildMetadataRef)).get(child.agentThreadId);
+ const childIdentity = collabChildIdentity(child, metadata);
switch (notification.method) {
case "turn/started": {
+ yield* markCollabChildOpen(child.agentThreadId);
const childTurnId =
typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string"
? ((notification.params as { turn: { id: string } }).turn.id as string)
@@ -1489,6 +1691,7 @@ export const makeCodexSessionRuntime = (
next.delete(child.agentThreadId);
return next;
});
+ yield* markCollabChildClosed(child.agentThreadId);
yield* emitEvent({
kind: "notification",
threadId: options.threadId,
diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts
index 5878d85f64..e7914061b1 100644
--- a/apps/server/src/provider/Layers/CursorAdapter.ts
+++ b/apps/server/src/provider/Layers/CursorAdapter.ts
@@ -977,6 +977,11 @@ export function makeCursorAdapter(
}
if (input.attachments && input.attachments.length > 0) {
for (const attachment of input.attachments) {
+ // Cursor ingests images only. Generic files reach the agent
+ // through the path line ProviderService puts in the prompt.
+ if (attachment.type !== "image") {
+ continue;
+ }
const attachmentPath = resolveAttachmentPath({
attachmentsDir: serverConfig.attachmentsDir,
attachment,
diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts
index 6cb71660a7..eeee17d9ac 100644
--- a/apps/server/src/provider/Layers/GrokAdapter.test.ts
+++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts
@@ -26,7 +26,13 @@ import {
} from "@t3tools/contracts";
import { ServerConfig } from "../../config.ts";
-import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts";
+import {
+ grokPromptSettlementBelongsToContext,
+ isGrokEnterPlanModeToolCall,
+ makeGrokAdapter,
+ nextGrokPlanModeActive,
+ selectGrokPermissionOptionId,
+} from "./GrokAdapter.ts";
const decodeGrokSettings = Schema.decodeSync(GrokSettings);
const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
@@ -89,6 +95,90 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
const makeTestAdapter = (binaryPath: string, options?: Parameters[1]) =>
makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie);
+it("detects enter_plan_mode tool calls from title and rawInput", () => {
+ assert.isTrue(
+ isGrokEnterPlanModeToolCall({
+ title: "enter_plan_mode",
+ data: { toolCallId: "1" },
+ }),
+ );
+ assert.isTrue(
+ isGrokEnterPlanModeToolCall({
+ title: "Plan mode entered",
+ data: { toolCallId: "1", rawInput: { variant: "EnterPlanMode" } },
+ }),
+ );
+ assert.isFalse(
+ isGrokEnterPlanModeToolCall({
+ title: "write",
+ data: { toolCallId: "1", rawInput: { file_path: "/tmp/x", content: "y" } },
+ }),
+ );
+});
+
+it("only sets planModeActive after a successful enter_plan_mode", () => {
+ const enter = {
+ title: "enter_plan_mode",
+ data: { toolCallId: "1" },
+ };
+ assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "pending" }));
+ assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "inProgress" }));
+ assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "completed" }));
+ assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "failed" }));
+ assert.isFalse(nextGrokPlanModeActive(true, { ...enter, status: "failed" }));
+ assert.isTrue(
+ nextGrokPlanModeActive(true, {
+ title: "write",
+ status: "completed",
+ data: { toolCallId: "2" },
+ }),
+ );
+});
+
+function grokPermissionRequest(
+ options: ReadonlyArray<{
+ readonly optionId: string;
+ readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
+ }>,
+) {
+ return {
+ sessionId: "mock-session-1",
+ toolCall: {
+ toolCallId: "tool-call-1",
+ title: "cat package.json",
+ kind: "execute" as const,
+ status: "pending" as const,
+ },
+ options: options.map((option) => ({
+ optionId: option.optionId,
+ name: option.kind,
+ kind: option.kind,
+ })),
+ };
+}
+
+it("maps Always allow to allow_once when Grok omits allow_always", () => {
+ const request = grokPermissionRequest([
+ { optionId: "allow-once", kind: "allow_once" },
+ { optionId: "reject-once", kind: "reject_once" },
+ ]);
+
+ assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-once");
+ assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once");
+ assert.equal(selectGrokPermissionOptionId(request, "decline"), "reject-once");
+});
+
+it("prefers allow_always when Grok offers it", () => {
+ const request = grokPermissionRequest([
+ { optionId: "allow-once", kind: "allow_once" },
+ { optionId: "allow-always", kind: "allow_always" },
+ { optionId: "reject-once", kind: "reject_once" },
+ ]);
+
+ assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-always");
+ assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once");
+});
+
it("requires a settlement to match the live Grok turn", () => {
const staleTurnId = TurnId.make("stale-turn");
const replacementTurnId = TurnId.make("replacement-turn");
@@ -418,6 +508,362 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
}),
);
+ it.effect("does not time out a Grok turn before ACP emits progress", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-watchdog-silent-turn");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_HANG_PROMPT_FOREVER: "1",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, {
+ turnInactivityTimeoutMs: 1_000,
+ });
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const turnStarted = yield* Deferred.make();
+ const turnCompleted =
+ yield* Deferred.make>();
+ const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ if (String(event.threadId) !== String(threadId)) {
+ return;
+ }
+ runtimeEvents.push(event);
+ if (event.type === "turn.started") {
+ yield* Deferred.succeed(turnStarted, undefined).pipe(Effect.ignore);
+ }
+ if (event.type === "turn.completed") {
+ yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "silence forever", attachments: [] })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(turnStarted);
+
+ yield* TestClock.adjust("5 seconds");
+ yield* Effect.yieldNow;
+ const steerSendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "keep reasoning", attachments: [] })
+ .pipe(Effect.forkChild);
+ for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) {
+ yield* Effect.yieldNow;
+ }
+ yield* TestClock.adjust("5 seconds");
+ yield* Effect.yieldNow;
+ assert.lengthOf(
+ runtimeEvents.filter(
+ (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
+ ),
+ 0,
+ );
+
+ yield* adapter.interruptTurn(threadId);
+ const completed = yield* Deferred.await(turnCompleted).pipe(
+ Effect.timeout("2 seconds"),
+ TestClock.withLive,
+ );
+ yield* Fiber.join(sendTurnFiber);
+ yield* Fiber.interrupt(steerSendTurnFiber);
+
+ assert.equal(completed.payload.state, "cancelled");
+ const session = (yield* adapter.listSessions()).find(
+ (candidate) => candidate.threadId === threadId,
+ );
+ assert.equal(session?.status, "ready");
+ assert.isUndefined(session?.activeTurnId);
+
+ yield* Fiber.interrupt(runtimeEventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("fails a Grok turn that stalls after ACP content begins", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-watchdog-content-stall");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_EMIT_CONTENT_THEN_HANG: "1",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, {
+ turnInactivityTimeoutMs: 1_000,
+ });
+ const contentDelta = yield* Deferred.make();
+ const turnCompleted =
+ yield* Deferred.make>();
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ if (String(event.threadId) !== String(threadId)) {
+ return;
+ }
+ runtimeEvents.push(event);
+ if (event.type === "content.delta") {
+ yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore);
+ }
+ if (event.type === "turn.completed") {
+ yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "start then stall", attachments: [] })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
+
+ yield* TestClock.adjust("999 millis");
+ yield* Effect.yieldNow;
+ assert.lengthOf(
+ runtimeEvents.filter(
+ (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
+ ),
+ 0,
+ );
+
+ yield* TestClock.adjust("1 millis");
+ for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
+ yield* Effect.yieldNow;
+ }
+ const completed = yield* Deferred.await(turnCompleted).pipe(
+ Effect.timeout("2 seconds"),
+ TestClock.withLive,
+ );
+ yield* Fiber.join(sendTurnFiber);
+
+ assert.equal(completed.payload.state, "failed");
+ assert.equal(
+ runtimeEvents.filter(
+ (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
+ ).length,
+ 1,
+ );
+
+ yield* Fiber.interrupt(runtimeEventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("refreshes Grok liveness when a turn is steered", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-watchdog-steer");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_EMIT_CONTENT_THEN_HANG: "1",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, {
+ turnInactivityTimeoutMs: 1_000,
+ });
+ const contentDelta = yield* Deferred.make();
+ const turnCompleted =
+ yield* Deferred.make>();
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ if (String(event.threadId) !== String(threadId)) {
+ return;
+ }
+ runtimeEvents.push(event);
+ if (event.type === "content.delta") {
+ yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore);
+ }
+ if (event.type === "turn.completed") {
+ yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const firstSendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "start then steer", attachments: [] })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
+
+ yield* TestClock.adjust("999 millis");
+ const steerSendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "continue working", attachments: [] })
+ .pipe(Effect.forkChild);
+ for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) {
+ yield* Effect.yieldNow;
+ }
+
+ yield* TestClock.adjust("1 millis");
+ for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
+ yield* Effect.yieldNow;
+ }
+ assert.lengthOf(
+ runtimeEvents.filter(
+ (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
+ ),
+ 0,
+ );
+
+ yield* Fiber.interrupt(steerSendTurnFiber);
+ yield* adapter.interruptTurn(threadId);
+ const completed = yield* Deferred.await(turnCompleted).pipe(
+ Effect.timeout("2 seconds"),
+ TestClock.withLive,
+ );
+ yield* Fiber.join(firstSendTurnFiber);
+ assert.equal(completed.payload.state, "cancelled");
+
+ yield* Fiber.interrupt(runtimeEventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("refreshes Grok liveness when ACP updates its plan", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-watchdog-plan-stall");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_EMIT_PLAN_THEN_HANG: "1",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, {
+ turnInactivityTimeoutMs: 1_000,
+ });
+ const planUpdated = yield* Deferred.make();
+ const turnCompleted =
+ yield* Deferred.make>();
+ const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ if (String(event.threadId) !== String(threadId)) {
+ return;
+ }
+ if (event.type === "turn.plan.updated") {
+ yield* Deferred.succeed(planUpdated, undefined).pipe(Effect.ignore);
+ }
+ if (event.type === "turn.completed") {
+ yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "update plan then stall", attachments: [] })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(planUpdated).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
+
+ yield* TestClock.adjust("1 second");
+ for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
+ yield* Effect.yieldNow;
+ }
+ const completed = yield* Deferred.await(turnCompleted).pipe(
+ Effect.timeout("2 seconds"),
+ TestClock.withLive,
+ );
+ yield* Fiber.join(sendTurnFiber);
+
+ assert.equal(completed.payload.state, "failed");
+ yield* Fiber.interrupt(runtimeEventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("settles a stalled Grok turn after the active-tool deadline", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-watchdog-active-tool");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG: "1",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, {
+ turnInactivityTimeoutMs: 1_000,
+ activeToolInactivityTimeoutMs: 5_000,
+ });
+ const activeTool = yield* Deferred.make();
+ const turnCompleted =
+ yield* Deferred.make>();
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.gen(function* () {
+ if (String(event.threadId) !== String(threadId)) {
+ return;
+ }
+ runtimeEvents.push(event);
+ if (event.type === "item.updated") {
+ yield* Deferred.succeed(activeTool, undefined).pipe(Effect.ignore);
+ }
+ if (event.type === "turn.completed") {
+ yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
+ }
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "run a long tool", attachments: [] })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(activeTool).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
+ for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
+ yield* Effect.yieldNow;
+ }
+
+ yield* TestClock.adjust("4999 millis");
+ yield* Effect.yieldNow;
+ assert.lengthOf(
+ runtimeEvents.filter(
+ (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
+ ),
+ 0,
+ );
+ assert.equal(
+ (yield* adapter.listSessions()).find((candidate) => candidate.threadId === threadId)
+ ?.status,
+ "running",
+ );
+
+ yield* TestClock.adjust("1 millis");
+ for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
+ yield* Effect.yieldNow;
+ }
+ const completed = yield* Deferred.await(turnCompleted).pipe(
+ Effect.timeout("2 seconds"),
+ TestClock.withLive,
+ );
+ yield* Fiber.join(sendTurnFiber);
+ assert.equal(completed.payload.state, "failed");
+
+ yield* Fiber.interrupt(runtimeEventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt");
@@ -943,6 +1389,64 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
}),
);
+ it.effect("surfaces Grok usage limits without clearing the selected model", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-usage-limit-error");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath);
+ const runtimeEvents: ProviderRuntimeEvent[] = [];
+ const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ Effect.sync(() => {
+ runtimeEvents.push(event);
+ }),
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
+ });
+
+ const error = yield* Effect.flip(
+ adapter.sendTurn({
+ threadId,
+ input: "hit the usage limit",
+ attachments: [],
+ }),
+ );
+ const readySessions = yield* adapter.listSessions();
+ const readySession = readySessions.find((session) => session.threadId === threadId);
+ const terminalEvents = runtimeEvents.filter(
+ (event) => event.type === "turn.completed" && event.threadId === threadId,
+ );
+
+ assert.equal(error._tag, "ProviderAdapterRequestError");
+ assert.include(error.message, "Grok usage limit reached. Try again later.");
+ assert.equal(readySession?.status, "ready");
+ assert.equal(readySession?.model, "grok-build");
+ assert.isUndefined(readySession?.activeTurnId);
+ assert.lengthOf(terminalEvents, 1);
+ const [terminalEvent] = terminalEvents;
+ assert.equal(terminalEvent?.type, "turn.completed");
+ if (terminalEvent?.type === "turn.completed") {
+ assert.equal(terminalEvent.payload.state, "failed");
+ assert.include(
+ terminalEvent.payload.errorMessage ?? "",
+ "Grok usage limit reached. Try again later.",
+ );
+ }
+
+ yield* Fiber.interrupt(runtimeEventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
it.effect("ignores replayed session/load updates when resuming a Grok session", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("grok-load-replay-filter");
@@ -1097,6 +1601,247 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
}),
);
+ it.effect("captures xAI exit_plan_mode as a proposed plan and unblocks the turn", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-xai-exit-plan-mode");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath);
+ const proposed =
+ yield* Deferred.make>();
+ const turnCompleted = yield* Deferred.make();
+
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
+ if (String(event.threadId) !== String(threadId)) {
+ return Effect.void;
+ }
+ if (event.type === "turn.proposed.completed") {
+ return Deferred.succeed(proposed, event).pipe(Effect.ignore);
+ }
+ if (event.type === "turn.completed") {
+ return Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore);
+ }
+ return Effect.void;
+ }).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+
+ yield* adapter.sendTurn({ threadId, input: "present the plan", attachments: [] });
+
+ const proposedEvent = yield* Deferred.await(proposed);
+ assert.equal(proposedEvent.type, "turn.proposed.completed");
+ assert.equal(proposedEvent.payload.planMarkdown, "# Exit plan\n\n- Step one\n- Step two");
+ assert.equal(proposedEvent.raw?.method, "_x.ai/exit_plan_mode");
+ yield* Deferred.await(turnCompleted);
+
+ yield* Fiber.interrupt(eventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("surfaces plan.md writes as a proposed plan while plan mode is active", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-xai-plan-md-write");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({ T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1" }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath);
+ const proposed =
+ yield* Deferred.make>();
+
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
+ if (String(event.threadId) !== String(threadId)) {
+ return Effect.void;
+ }
+ if (event.type === "turn.proposed.completed") {
+ return Deferred.succeed(proposed, event).pipe(Effect.ignore);
+ }
+ return Effect.void;
+ }).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+
+ yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] });
+
+ const proposedEvent = yield* Deferred.await(proposed);
+ assert.equal(
+ proposedEvent.payload.planMarkdown,
+ "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it",
+ );
+ assert.equal(proposedEvent.raw?.method, "session/update");
+
+ yield* Fiber.interrupt(eventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("keeps a Grok turn running when Always allow has no allow_always option", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-always-allow-without-allow-always");
+ const tempDir = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-")),
+ );
+ const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_REQUEST_LOG_PATH: requestLogPath,
+ T3_ACP_EMIT_TOOL_CALLS: "1",
+ T3_ACP_OMIT_ALLOW_ALWAYS: "1",
+ T3_ACP_PERMISSION_REQUEST_COUNT: "2",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath);
+ const openedCount = yield* Ref.make(0);
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ event.type === "request.opened"
+ ? Effect.gen(function* () {
+ yield* Ref.update(openedCount, (count) => count + 1);
+ yield* adapter.respondToRequest(
+ threadId,
+ ApprovalRequestId.make(String(event.requestId)),
+ "acceptForSession",
+ );
+ })
+ : Effect.void,
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "approval-required",
+ });
+ yield* adapter.sendTurn({
+ threadId,
+ input: "approve this session",
+ attachments: [],
+ });
+
+ assert.equal(yield* Ref.get(openedCount), 1);
+
+ const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
+ const permissionResults = requests.filter(
+ (entry) =>
+ !("method" in entry) &&
+ typeof entry.result === "object" &&
+ entry.result !== null &&
+ "outcome" in entry.result &&
+ typeof entry.result.outcome === "object" &&
+ entry.result.outcome !== null &&
+ "optionId" in entry.result.outcome,
+ );
+ assert.equal(permissionResults.length, 2);
+ assert.isTrue(
+ permissionResults.every(
+ (entry) =>
+ typeof entry.result === "object" &&
+ entry.result !== null &&
+ "outcome" in entry.result &&
+ typeof entry.result.outcome === "object" &&
+ entry.result.outcome !== null &&
+ "optionId" in entry.result.outcome &&
+ entry.result.outcome.optionId === "allow-once",
+ ),
+ );
+
+ yield* Fiber.interrupt(eventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("asks before a different command after Always allow this session", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-session-approval-scope");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_EMIT_TOOL_CALLS: "1",
+ T3_ACP_OMIT_ALLOW_ALWAYS: "1",
+ T3_ACP_PERMISSION_REQUEST_COUNT: "2",
+ T3_ACP_PERMISSION_TITLE: "Terminal",
+ T3_ACP_SECOND_PERMISSION_COMMAND: "rm server/package.json",
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath);
+ const openedCount = yield* Ref.make(0);
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
+ event.type === "request.opened"
+ ? Effect.gen(function* () {
+ const count = yield* Ref.updateAndGet(openedCount, (value) => value + 1);
+ yield* adapter.respondToRequest(
+ threadId,
+ ApprovalRequestId.make(String(event.requestId)),
+ count === 1 ? "acceptForSession" : "decline",
+ );
+ })
+ : Effect.void,
+ ).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "approval-required",
+ });
+ yield* adapter.sendTurn({ threadId, input: "check approval scope", attachments: [] });
+ assert.equal(yield* Ref.get(openedCount), 2);
+ yield* Fiber.interrupt(eventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("captures a plan under the provider instance GROK_HOME", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-instance-plan-home");
+ const grokHome = yield* Effect.promise(() =>
+ NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-instance-home-")),
+ );
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({
+ T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1",
+ T3_ACP_PLAN_ROOT: grokHome,
+ }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, {
+ environment: { ...process.env, GROK_HOME: grokHome },
+ });
+ const plans = yield* Ref.make>([]);
+ const completed = yield* Deferred.make();
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
+ if (event.type === "turn.proposed.completed") {
+ return Ref.update(plans, (current) => [...current, event.payload.planMarkdown]);
+ }
+ return event.type === "turn.completed"
+ ? Deferred.succeed(completed, undefined).pipe(Effect.asVoid)
+ : Effect.void;
+ }).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+ yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] });
+ yield* Deferred.await(completed);
+ assert.deepEqual(yield* Ref.get(plans), [
+ "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it",
+ ]);
+ yield* Fiber.interrupt(eventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
it.effect("handles xAI ask_user_question extension requests", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("grok-xai-ask-user-question");
@@ -1159,6 +1904,82 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
}),
);
+ it.effect("settles a stalled Grok turn after its first activity is user input", () =>
+ Effect.gen(function* () {
+ const threadId = ThreadId.make("grok-xai-ask-user-question");
+ const wrapperPath = yield* Effect.promise(() =>
+ makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG: "1" }),
+ );
+ const adapter = yield* makeTestAdapter(wrapperPath, {
+ turnInactivityTimeoutMs: 1_000,
+ });
+ const requested =
+ yield* Deferred.make>();
+ const resolved =
+ yield* Deferred.make>();
+ const completed =
+ yield* Deferred.make>();
+
+ const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
+ if (String(event.threadId) !== String(threadId)) {
+ return Effect.void;
+ }
+ if (event.type === "user-input.requested") {
+ return Deferred.succeed(requested, event).pipe(Effect.ignore);
+ }
+ if (event.type === "user-input.resolved") {
+ return Deferred.succeed(resolved, event).pipe(Effect.ignore);
+ }
+ if (event.type === "turn.completed") {
+ return Deferred.succeed(completed, event).pipe(Effect.ignore);
+ }
+ return Effect.void;
+ }).pipe(Effect.forkChild);
+
+ yield* adapter.startSession({
+ threadId,
+ provider: ProviderDriverKind.make("grok"),
+ cwd: process.cwd(),
+ runtimeMode: "full-access",
+ });
+
+ const sendTurnFiber = yield* adapter
+ .sendTurn({ threadId, input: "ask before continuing", attachments: [] })
+ .pipe(Effect.forkChild);
+
+ const requestedEvent = yield* Deferred.await(requested);
+ assert.equal(requestedEvent.payload.questions.length, 1);
+ assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?");
+ assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?");
+ assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question");
+
+ yield* adapter.respondToUserInput(
+ threadId,
+ ApprovalRequestId.make(String(requestedEvent.requestId)),
+ {
+ "Which scope should Grok use?": "Workspace",
+ },
+ );
+
+ const resolvedEvent = yield* Deferred.await(resolved);
+ assert.deepEqual(resolvedEvent.payload.answers, {
+ "Which scope should Grok use?": "Workspace",
+ });
+ assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId));
+
+ yield* TestClock.adjust("1 second");
+ const completedEvent = yield* Deferred.await(completed).pipe(
+ Effect.timeout("2 seconds"),
+ TestClock.withLive,
+ );
+ assert.equal(completedEvent.payload.state, "failed");
+ yield* Fiber.join(sendTurnFiber);
+
+ yield* Fiber.interrupt(eventsFiber);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
it.effect("continues streaming events when native notification logging fails", () =>
Effect.gen(function* () {
const threadId = ThreadId.make("grok-native-log-failure");
diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts
index 1004339f3e..627a572b3f 100644
--- a/apps/server/src/provider/Layers/GrokAdapter.ts
+++ b/apps/server/src/provider/Layers/GrokAdapter.ts
@@ -12,9 +12,14 @@ import {
type ThreadId,
TurnId,
} from "@t3tools/contracts";
+import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
+import { stableStringify } from "@t3tools/shared/relaySigning";
+import * as Clock from "effect/Clock";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
@@ -22,6 +27,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PubSub from "effect/PubSub";
+import * as Queue from "effect/Queue";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
@@ -56,15 +62,21 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
import {
applyGrokAcpModelSelection,
currentGrokModelIdFromSessionSetup,
+ currentGrokReasoningEffortFromSessionSetup,
makeGrokAcpRuntime,
+ normalizeGrokReasoningEffort,
resolveGrokAcpBaseModelId,
} from "../acp/GrokAcpSupport.ts";
import {
+ extractGrokPlanMarkdownFromToolCallData,
extractXAiAskUserQuestions,
+ extractXAiExitPlanMarkdown,
makeXAiAskUserQuestionCancelledResponse,
makeXAiAskUserQuestionResponse,
+ makeXAiExitPlanModeCapturedResponse,
promptResponseHasMissingXAiStopReason,
XAiAskUserQuestionRequest,
+ XAiExitPlanModeRequest,
} from "../acp/XAiAcpExtension.ts";
import { mergeProviderSessionEnvironment } from "../ProviderInstanceEnvironment.ts";
import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";
@@ -74,6 +86,15 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri
const PROVIDER = ProviderDriverKind.make("grok");
const GROK_RESUME_VERSION = 1 as const;
+const NANOS_PER_MILLI = 1_000_000n;
+// ACP does not expose Grok's private `streaming_reasoning` phase. Once it has
+// emitted standard ACP progress, ten silent minutes is long enough to avoid
+// treating legitimate reasoning as a stalled stream.
+const DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1_000;
+// A tool can legitimately run without emitting text for much longer than
+// reasoning. It still needs a deadline so a lost tool update cannot leave the
+// turn working forever.
+const DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1_000;
function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
const result = encodeUnknownJsonStringExit(input);
@@ -85,6 +106,10 @@ export interface GrokAdapterLiveOptions {
readonly nativeEventLogPath?: string;
readonly nativeEventLogger?: EventNdjsonLogger;
readonly instanceId?: ProviderInstanceId;
+ /** Override the conservative ACP turn liveness timeout in focused tests. */
+ readonly turnInactivityTimeoutMs?: number;
+ /** Override the longer active-tool liveness timeout in focused tests. */
+ readonly activeToolInactivityTimeoutMs?: number;
}
interface PendingApproval {
@@ -99,6 +124,10 @@ interface PendingUserInput {
readonly resolution: Deferred.Deferred;
}
+interface GrokTurnLivenessSignal {
+ readonly turnId: TurnId;
+}
+
interface GrokSessionContext {
readonly threadId: ThreadId;
readonly acpSessionId: string;
@@ -110,6 +139,14 @@ interface GrokSessionContext {
readonly pendingUserInputs: Map;
turns: Array<{ id: TurnId; items: Array }>;
lastPlanFingerprint: string | undefined;
+ /**
+ * Latest plan.md body + turn it was emitted for. Dedupe is turn-scoped so a
+ * later turn re-proposing the same text still gets a new proposed-plan card.
+ */
+ lastKnownProposedPlanMarkdown: string | undefined;
+ lastKnownProposedPlanTurnId: TurnId | undefined;
+ /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */
+ planModeActive: boolean;
activeTurnId: TurnId | undefined;
/** Turns already interrupted; late prompt RPCs must not resurrect them. */
interruptedTurnIds: Set;
@@ -117,7 +154,15 @@ interface GrokSessionContext {
* >0 means a turn is actively running, so a new sendTurn is a steer that
* continues it, and only the last remaining prompt settles the turn. */
promptsInFlight: number;
+ readonly livenessSignals: Queue.Queue;
+ livenessTurnId: TurnId | undefined;
+ lastTurnActivityAtNanos: bigint | undefined;
+ readonly activeToolCallIds: Set;
+ livenessUpdatesInFlight: number;
+ /** Prompt RPCs that returned before their turn settlement acquired the lock. */
+ promptResponsesReady: number;
currentModelId: string | undefined;
+ currentReasoningEffort: string | undefined;
stopped: boolean;
}
@@ -165,6 +210,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined
const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId;
+function clearProposedPlanFallback(ctx: GrokSessionContext): void {
+ ctx.lastKnownProposedPlanMarkdown = undefined;
+ ctx.lastKnownProposedPlanTurnId = undefined;
+ ctx.planModeActive = false;
+}
+
+/** Detect Grok's enter_plan_mode tool call from ACP tool state. */
+export function isGrokEnterPlanModeToolCall(toolCall: {
+ readonly title?: string;
+ readonly data: Record;
+}): boolean {
+ const title = toolCall.title?.trim().toLowerCase() ?? "";
+ if (
+ title === "enter_plan_mode" ||
+ title === "plan: enter" ||
+ title === "plan mode entered" ||
+ title.includes("enter_plan_mode")
+ ) {
+ return true;
+ }
+ const rawInput = toolCall.data.rawInput;
+ if (isRecord(rawInput) && rawInput.variant === "EnterPlanMode") {
+ return true;
+ }
+ return false;
+}
+
+/** Failed enter_plan_mode must not leave planModeActive stuck on. */
+export function nextGrokPlanModeActive(
+ currentlyActive: boolean,
+ toolCall: {
+ readonly title?: string;
+ readonly status?: "pending" | "inProgress" | "completed" | "failed";
+ readonly data: Record;
+ },
+): boolean {
+ if (!isGrokEnterPlanModeToolCall(toolCall)) {
+ return currentlyActive;
+ }
+ if (toolCall.status === "failed") {
+ return false;
+ }
+ if (toolCall.status === "completed" || toolCall.status === "inProgress") {
+ return true;
+ }
+ return currentlyActive;
+}
+
const resolveSessionCallbackTurnId = (
sessions: ReadonlyMap,
threadId: ThreadId,
@@ -180,26 +273,38 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined {
return { sessionId: raw.sessionId.trim() };
}
-function selectPermissionOptionId(
+export function selectGrokPermissionOptionId(
request: EffectAcpSchema.RequestPermissionRequest,
decision: Exclude,
): string | undefined {
- const kind =
+ const preferredKind =
decision === "acceptForSession"
? "allow_always"
: decision === "accept"
? "allow_once"
: "reject_once";
- const option = request.options.find((entry) => entry.kind === kind);
- return option?.optionId.trim() || undefined;
+ const preferred = request.options.find((entry) => entry.kind === preferredKind);
+ const preferredId = preferred?.optionId.trim();
+ if (preferredId) {
+ return preferredId;
+ }
+ // Grok 4.6 often omits allow_always. T3 still offers "Always allow this session".
+ if (decision === "acceptForSession") {
+ const once = request.options.find((entry) => entry.kind === "allow_once");
+ const onceId = once?.optionId.trim();
+ if (onceId) {
+ return onceId;
+ }
+ }
+ return undefined;
}
function selectAutoApprovedPermissionOption(
request: EffectAcpSchema.RequestPermissionRequest,
): string | undefined {
return (
- selectPermissionOptionId(request, "acceptForSession") ??
- selectPermissionOptionId(request, "accept")
+ selectGrokPermissionOptionId(request, "acceptForSession") ??
+ selectGrokPermissionOptionId(request, "accept")
);
}
@@ -241,10 +346,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
const managedNativeEventLogger =
options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory();
+ const hostPlatform = yield* HostProcessPlatform;
+ const hostEnvironment = yield* HostProcessEnvironment;
+ const grokPlanPathHost = {
+ platform: hostPlatform,
+ environment: options?.environment ?? hostEnvironment,
+ };
const sessions = new Map();
const threadLocksRef = yield* SynchronizedRef.make(new Map());
const runtimeEventPubSub = yield* PubSub.unbounded();
+ const requestedTurnInactivityTimeoutMs = options?.turnInactivityTimeoutMs;
+ const turnInactivityTimeoutMs =
+ typeof requestedTurnInactivityTimeoutMs === "number" &&
+ Number.isFinite(requestedTurnInactivityTimeoutMs)
+ ? Math.max(1, Math.floor(requestedTurnInactivityTimeoutMs))
+ : DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS;
+ const turnInactivityTimeoutNanos = BigInt(turnInactivityTimeoutMs) * NANOS_PER_MILLI;
+ const requestedActiveToolInactivityTimeoutMs = options?.activeToolInactivityTimeoutMs;
+ const activeToolInactivityTimeoutMs =
+ typeof requestedActiveToolInactivityTimeoutMs === "number" &&
+ Number.isFinite(requestedActiveToolInactivityTimeoutMs)
+ ? Math.max(1, Math.floor(requestedActiveToolInactivityTimeoutMs))
+ : DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS;
+ const activeToolInactivityTimeoutNanos =
+ BigInt(activeToolInactivityTimeoutMs) * NANOS_PER_MILLI;
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
const randomUUIDv4 = crypto.randomUUIDv4.pipe(
@@ -295,6 +421,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
const withThreadLock = (threadId: string, effect: Effect.Effect) =>
Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
+ const signalTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) =>
+ Queue.offer(ctx.livenessSignals, { turnId }).pipe(Effect.asVoid);
+
+ const beginTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) =>
+ Effect.sync(() => {
+ ctx.livenessTurnId = turnId;
+ // Do not start a deadline until ACP has made observable progress.
+ // Grok's private reasoning phase is not present in the ACP stream.
+ ctx.lastTurnActivityAtNanos = undefined;
+ ctx.activeToolCallIds.clear();
+ });
+
+ const clearTurnLiveness = (ctx: GrokSessionContext) => {
+ const turnId = ctx.livenessTurnId;
+ ctx.livenessTurnId = undefined;
+ ctx.lastTurnActivityAtNanos = undefined;
+ ctx.activeToolCallIds.clear();
+ ctx.livenessUpdatesInFlight = 0;
+ ctx.promptResponsesReady = 0;
+ return turnId === undefined ? Effect.void : signalTurnLiveness(ctx, turnId);
+ };
+
+ const recordTurnActivity = Effect.fn("GrokAdapter.recordTurnActivity")(function* (
+ ctx: GrokSessionContext,
+ turnId: TurnId,
+ event: Extract<
+ AcpSessionRuntime.AcpSessionRuntimeEvent,
+ {
+ _tag:
+ | "AssistantItemStarted"
+ | "AssistantItemCompleted"
+ | "PlanUpdated"
+ | "ToolCallUpdated"
+ | "ContentDelta";
+ }
+ >,
+ ) {
+ if (
+ ctx.livenessTurnId !== turnId ||
+ (event._tag === "ContentDelta" && event.text.length === 0)
+ ) {
+ return;
+ }
+ ctx.livenessUpdatesInFlight += 1;
+ try {
+ const activityAtNanos = yield* Clock.monotonicTimeNanos;
+ if (ctx.livenessTurnId !== turnId || ctx.interruptedTurnIds.has(turnId)) {
+ return;
+ }
+ if (event._tag === "ToolCallUpdated") {
+ if (event.toolCall.status === "completed" || event.toolCall.status === "failed") {
+ ctx.activeToolCallIds.delete(event.toolCall.toolCallId);
+ } else {
+ // A tool update without a terminal status receives a longer
+ // deadline so a long-running tool is not mistaken for a stall.
+ ctx.activeToolCallIds.add(event.toolCall.toolCallId);
+ }
+ }
+ ctx.lastTurnActivityAtNanos = activityAtNanos;
+ } finally {
+ // Decrement before signaling. The watchdog treats in-flight updates as a
+ // pause; if it consumed a signal while the counter was still > 0 it would
+ // wait on the next take with no follow-up wake after this decrement.
+ ctx.livenessUpdatesInFlight = Math.max(0, ctx.livenessUpdatesInFlight - 1);
+ yield* signalTurnLiveness(ctx, turnId);
+ }
+ });
+
+ const hasLivenessPause = (ctx: GrokSessionContext) =>
+ ctx.pendingApprovals.size > 0 ||
+ ctx.pendingUserInputs.size > 0 ||
+ ctx.livenessUpdatesInFlight > 0;
+
+ const livenessTimeoutFor = (ctx: GrokSessionContext) =>
+ ctx.activeToolCallIds.size > 0
+ ? {
+ milliseconds: activeToolInactivityTimeoutMs,
+ nanos: activeToolInactivityTimeoutNanos,
+ }
+ : { milliseconds: turnInactivityTimeoutMs, nanos: turnInactivityTimeoutNanos };
+
+ const signalSessionTurnLiveness = (threadId: ThreadId, turnId: TurnId | undefined) => {
+ const ctx = sessions.get(threadId);
+ return ctx && turnId !== undefined ? signalTurnLiveness(ctx, turnId) : Effect.void;
+ };
+
+ const resumeSessionTurnLiveness = Effect.fn("GrokAdapter.resumeSessionTurnLiveness")(function* (
+ threadId: ThreadId,
+ turnId: TurnId | undefined,
+ ) {
+ const ctx = sessions.get(threadId);
+ if (!ctx || turnId === undefined || ctx.livenessTurnId !== turnId) {
+ return;
+ }
+ // An approval or user-input wait can last longer than the watchdog.
+ // Its resolution gives the provider a fresh window to resume output.
+ ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos;
+ yield* signalTurnLiveness(ctx, turnId);
+ });
+
+ const refreshSessionTurnLiveness = Effect.fn("GrokAdapter.refreshSessionTurnLiveness")(
+ function* (threadId: ThreadId, turnId: TurnId | undefined) {
+ const ctx = sessions.get(threadId);
+ if (
+ !ctx ||
+ turnId === undefined ||
+ ctx.livenessTurnId !== turnId ||
+ ctx.lastTurnActivityAtNanos === undefined
+ ) {
+ return;
+ }
+ ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos;
+ yield* signalTurnLiveness(ctx, turnId);
+ },
+ );
+
+ const markPromptResponseReady = Effect.fn("GrokAdapter.markPromptResponseReady")(function* (
+ threadId: ThreadId,
+ acpSessionId: string,
+ turnId: TurnId,
+ ) {
+ const ctx = sessions.get(threadId);
+ if (
+ ctx &&
+ ctx.acpSessionId === acpSessionId &&
+ !ctx.stopped &&
+ !ctx.interruptedTurnIds.has(turnId) &&
+ ctx.livenessTurnId === turnId &&
+ ctx.activeTurnId === turnId &&
+ ctx.session.activeTurnId === turnId
+ ) {
+ ctx.promptResponsesReady += 1;
+ yield* signalTurnLiveness(ctx, turnId);
+ }
+ });
+
+ const consumePromptResponseReady = (ctx: GrokSessionContext) => {
+ ctx.promptResponsesReady = Math.max(0, ctx.promptResponsesReady - 1);
+ };
+
const settlePromptInFlight = (
threadId: ThreadId,
turnId: TurnId,
@@ -374,6 +640,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
updatedAt,
};
}
+ yield* clearTurnLiveness(liveCtx);
return;
}
settleTurnId = fallbackTurnId;
@@ -390,6 +657,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
}
liveCtx.promptsInFlight = remainingPrompts;
}
+ yield* clearTurnLiveness(liveCtx);
const updatedAt = yield* nowIso;
const canEmitTurnCompletion =
liveCtx.session.status === "running" || liveCtx.session.status === "connecting";
@@ -398,6 +666,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
options?.completedStopReason !== undefined && canEmitTurnCompletion;
const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session;
liveCtx.activeTurnId = undefined;
+ // Drop turn-scoped plan fallback so a later empty exit_plan cannot
+ // resurrect this turn's markdown as a fresh proposal.
+ clearProposedPlanFallback(liveCtx);
liveCtx.session = {
...readySession,
status: "ready",
@@ -433,6 +704,104 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
}
});
+ const isLiveTurn = (ctx: GrokSessionContext, turnId: TurnId) =>
+ ctx.promptsInFlight > 0 &&
+ ctx.promptsInFlight > ctx.promptResponsesReady &&
+ ctx.activeTurnId === turnId &&
+ ctx.session.activeTurnId === turnId &&
+ (ctx.session.status === "running" || ctx.session.status === "connecting");
+
+ const settleStalledTurn = Effect.fn("GrokAdapter.settleStalledTurn")(function* (
+ ctx: GrokSessionContext,
+ turnId: TurnId,
+ ) {
+ return yield* withThreadLock(
+ ctx.threadId,
+ Effect.gen(function* () {
+ const liveCtx = sessions.get(ctx.threadId);
+ if (
+ liveCtx !== ctx ||
+ ctx.stopped ||
+ !isLiveTurn(ctx, turnId) ||
+ ctx.interruptedTurnIds.has(turnId) ||
+ hasLivenessPause(ctx)
+ ) {
+ return;
+ }
+ const lastActivityAtNanos = ctx.lastTurnActivityAtNanos;
+ if (lastActivityAtNanos === undefined) {
+ return;
+ }
+ const nowNanos = yield* Clock.monotonicTimeNanos;
+ if (
+ ctx.interruptedTurnIds.has(turnId) ||
+ !isLiveTurn(ctx, turnId) ||
+ hasLivenessPause(ctx) ||
+ nowNanos - lastActivityAtNanos < livenessTimeoutFor(ctx).nanos
+ ) {
+ return;
+ }
+
+ // Mark before cancel/drain so notifications already in flight finish
+ // before the terminal event, while late notifications are dropped.
+ ctx.interruptedTurnIds.add(turnId);
+ yield* Effect.ignore(
+ ctx.acp.cancel.pipe(
+ Effect.mapError((error) =>
+ mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/cancel", error),
+ ),
+ ),
+ );
+ yield* Effect.ignore(ctx.acp.drainEvents);
+ yield* settlePromptInFlight(ctx.threadId, turnId, ctx.acpSessionId, {
+ errorMessage: `Grok ACP turn stalled without content or tool progress for ${livenessTimeoutFor(ctx).milliseconds}ms.`,
+ settleAllPrompts: true,
+ });
+ }),
+ );
+ });
+
+ const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")(
+ function* (ctx: GrokSessionContext) {
+ while (true) {
+ if (ctx.stopped) {
+ return;
+ }
+ const turnId = ctx.livenessTurnId;
+ if (
+ turnId === undefined ||
+ ctx.interruptedTurnIds.has(turnId) ||
+ !isLiveTurn(ctx, turnId) ||
+ hasLivenessPause(ctx)
+ ) {
+ yield* Queue.take(ctx.livenessSignals);
+ continue;
+ }
+
+ const lastActivityAtNanos = ctx.lastTurnActivityAtNanos;
+ if (lastActivityAtNanos === undefined) {
+ yield* Queue.take(ctx.livenessSignals);
+ continue;
+ }
+ const nowNanos = yield* Clock.monotonicTimeNanos;
+ const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos);
+ if (remainingNanos <= 0n) {
+ yield* settleStalledTurn(ctx, turnId);
+ continue;
+ }
+
+ const wakeReason = yield* Effect.raceFirst(
+ Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)),
+ Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)),
+ );
+ if (wakeReason === "timeout") {
+ yield* settleStalledTurn(ctx, turnId);
+ }
+ }
+ },
+ Effect.catch(() => Effect.void),
+ );
+
const logNative = (threadId: ThreadId, method: string, payload: unknown) =>
Effect.gen(function* () {
if (!nativeEventLogger) return;
@@ -496,6 +865,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
);
});
+ /** Surface Grok plan.md as T3's proposed-plan card (while writing + on exit). */
+ const emitProposedPlanCompleted = (
+ ctx: GrokSessionContext,
+ turnId: TurnId | undefined,
+ stamp: { readonly eventId: EventId; readonly createdAt: string },
+ planMarkdown: string,
+ raw: { readonly method: string; readonly payload: unknown },
+ ) =>
+ Effect.gen(function* () {
+ const trimmed = planMarkdown.trim();
+ if (trimmed.length === 0) {
+ ctx.lastKnownProposedPlanMarkdown = "";
+ ctx.lastKnownProposedPlanTurnId = turnId;
+ return;
+ }
+ // Turn-scoped dedupe: identical text on a later turn must still emit.
+ if (
+ ctx.lastKnownProposedPlanMarkdown === trimmed &&
+ ctx.lastKnownProposedPlanTurnId === turnId
+ ) {
+ return;
+ }
+ ctx.lastKnownProposedPlanMarkdown = trimmed;
+ ctx.lastKnownProposedPlanTurnId = turnId;
+ yield* offerRuntimeEvent({
+ type: "turn.proposed.completed",
+ ...stamp,
+ provider: PROVIDER,
+ threadId: ctx.threadId,
+ turnId,
+ payload: { planMarkdown: trimmed },
+ raw: {
+ source: "acp.grok.extension",
+ method: raw.method,
+ payload: raw.payload,
+ },
+ });
+ });
+
const requireSession = (
threadId: ThreadId,
): Effect.Effect => {
@@ -557,6 +965,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
const pendingApprovals = new Map();
const pendingUserInputs = new Map();
+ const sessionApprovedOperations = new Set();
const sessionScope = yield* Scope.make("sequential");
let sessionScopeTransferred = false;
yield* Effect.addFinalizer(() =>
@@ -576,6 +985,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
environment: mergeProviderSessionEnvironment(options?.environment, input.env),
childProcessSpawner,
cwd,
+ runtimeMode: input.runtimeMode,
...(resumeSessionId ? { resumeSessionId } : {}),
clientInfo: { name: "t3-code", version: "0.0.0" },
...(mcpSession
@@ -622,6 +1032,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
const resolution = yield* Deferred.make();
const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
pendingUserInputs.set(requestId, { resolution });
+ yield* signalSessionTurnLiveness(input.threadId, turnId);
yield* offerRuntimeEvent({
type: "user-input.requested",
...(yield* makeEventStamp()),
@@ -638,6 +1049,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
});
const resolved = yield* Deferred.await(resolution);
pendingUserInputs.delete(requestId);
+ yield* resumeSessionTurnLiveness(input.threadId, turnId);
const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {};
yield* offerRuntimeEvent({
type: "user-input.resolved",
@@ -664,12 +1076,77 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
),
{ discard: true },
);
+ // Grok intercepts exit_plan_mode and reverse-requests client approval.
+ // Capture plan into T3 proposed-plan UI and abandon the native gate so
+ // the turn does not hang (Claude ExitPlanMode pattern).
+ yield* Effect.forEach(
+ ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const,
+ (method) =>
+ acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) =>
+ mapAcpCallbackFailure(
+ Effect.gen(function* () {
+ yield* logNative(input.threadId, method, params);
+ const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
+ const ctx = sessions.get(input.threadId);
+ const planMarkdown = extractXAiExitPlanMarkdown(
+ params,
+ ctx?.lastKnownProposedPlanMarkdown,
+ );
+ if (ctx) {
+ yield* emitProposedPlanCompleted(
+ ctx,
+ turnId,
+ yield* makeEventStamp(),
+ planMarkdown,
+ { method, payload: params },
+ );
+ ctx.planModeActive = false;
+ } else {
+ yield* offerRuntimeEvent({
+ type: "turn.proposed.completed",
+ ...(yield* makeEventStamp()),
+ provider: PROVIDER,
+ threadId: input.threadId,
+ turnId,
+ payload: { planMarkdown },
+ raw: {
+ source: "acp.grok.extension",
+ method,
+ payload: params,
+ },
+ });
+ }
+ return makeXAiExitPlanModeCapturedResponse();
+ }),
+ ),
+ ),
+ { discard: true },
+ );
yield* acp.handleRequestPermission((params) =>
mapAcpCallbackFailure(
Effect.gen(function* () {
yield* logNative(input.threadId, "session/request_permission", params);
- if (input.runtimeMode === "full-access") {
- const autoApprovedOptionId = selectAutoApprovedPermissionOption(params);
+ const permissionRequest = parsePermissionRequest(params);
+ const command = permissionRequest.toolCall?.command;
+ const { kind, title, rawInput, locations } = params.toolCall;
+ let operationInput = rawInput;
+ if (isRecord(rawInput) && rawInput.variant === "Bash") {
+ const { description: _description, ...shellInput } = rawInput;
+ operationInput = shellInput;
+ }
+ // Remember the operation, not the tool-call id or every future tool.
+ // Generic titles without input cannot identify an operation safely.
+ const approvalKey =
+ command || (isRecord(rawInput) && Object.keys(rawInput).length > 0)
+ ? stableStringify({ kind, title, command, input: operationInput, locations })
+ : undefined;
+ const alreadyApproved =
+ approvalKey !== undefined && sessionApprovedOperations.has(approvalKey);
+ if (input.runtimeMode === "full-access" || alreadyApproved) {
+ const autoApprovedOptionId =
+ input.runtimeMode === "full-access"
+ ? selectAutoApprovedPermissionOption(params)
+ : selectGrokPermissionOptionId(params, "accept");
if (autoApprovedOptionId !== undefined) {
return {
outcome: {
@@ -679,12 +1156,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
};
}
}
- const permissionRequest = parsePermissionRequest(params);
const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
const runtimeRequestId = RuntimeRequestId.make(requestId);
const decision = yield* Deferred.make();
const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
pendingApprovals.set(requestId, { decision });
+ yield* signalSessionTurnLiveness(input.threadId, turnId);
yield* offerRuntimeEvent(
makeAcpRequestOpenedEvent({
stamp: yield* makeEventStamp(),
@@ -705,6 +1182,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
);
const resolved = yield* Deferred.await(decision);
pendingApprovals.delete(requestId);
+ yield* resumeSessionTurnLiveness(input.threadId, turnId);
yield* offerRuntimeEvent(
makeAcpRequestResolvedEvent({
stamp: yield* makeEventStamp(),
@@ -717,7 +1195,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
}),
);
const selectedOptionId =
- resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved);
+ resolved === "cancel"
+ ? undefined
+ : selectGrokPermissionOptionId(params, resolved);
+ if (
+ resolved === "acceptForSession" &&
+ selectedOptionId &&
+ approvalKey !== undefined
+ ) {
+ sessionApprovedOperations.add(approvalKey);
+ }
return {
outcome: selectedOptionId
? {
@@ -739,10 +1226,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
const requestedStartModelId = grokModelSelection?.model
? resolveGrokAcpBaseModelId(grokModelSelection.model)
: undefined;
+ const currentStartModelId = currentGrokModelIdFromSessionSetup(
+ started.sessionSetupResult,
+ );
+ const currentStartReasoningEffort = currentGrokReasoningEffortFromSessionSetup(
+ started.sessionSetupResult,
+ );
+ const requestedStartReasoningEffort = getModelSelectionStringOptionValue(
+ grokModelSelection,
+ "reasoningEffort",
+ );
const boundModelId = yield* applyGrokAcpModelSelection({
runtime: acp,
- currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
+ currentModelId: currentStartModelId,
+ currentReasoningEffort: currentStartReasoningEffort,
requestedModelId: requestedStartModelId,
+ requestedReasoningEffort: requestedStartReasoningEffort,
mapError: (cause) =>
mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
});
@@ -775,10 +1274,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
pendingUserInputs,
turns: [],
lastPlanFingerprint: undefined,
+ lastKnownProposedPlanMarkdown: undefined,
+ lastKnownProposedPlanTurnId: undefined,
+ planModeActive: false,
activeTurnId: undefined,
interruptedTurnIds: new Set(),
promptsInFlight: 0,
+ livenessSignals: yield* Queue.sliding(1),
+ livenessTurnId: undefined,
+ lastTurnActivityAtNanos: undefined,
+ activeToolCallIds: new Set(),
+ livenessUpdatesInFlight: 0,
+ promptResponsesReady: 0,
currentModelId: boundModelId,
+ currentReasoningEffort:
+ requestedStartReasoningEffort !== undefined
+ ? normalizeGrokReasoningEffort(requestedStartReasoningEffort)
+ : currentStartReasoningEffort,
stopped: false,
};
@@ -808,6 +1320,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
) {
return;
}
+ if (
+ event._tag === "AssistantItemStarted" ||
+ event._tag === "AssistantItemCompleted" ||
+ event._tag === "PlanUpdated" ||
+ event._tag === "ToolCallUpdated" ||
+ event._tag === "ContentDelta"
+ ) {
+ yield* recordTurnActivity(ctx, notificationTurnId, event);
+ }
const stamp = yield* makeEventStamp();
switch (event._tag) {
@@ -848,7 +1369,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
"session/update",
);
return;
- case "ToolCallUpdated":
+ case "ToolCallUpdated": {
yield* offerRuntimeEvent(
makeAcpToolCallEvent({
stamp,
@@ -859,7 +1380,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
rawPayload: event.rawPayload,
}),
);
+ ctx.planModeActive = nextGrokPlanModeActive(ctx.planModeActive, event.toolCall);
+ // Only promote session plan.md writes while plan mode is
+ // active — avoids treating unrelated plan files as proposals.
+ // Fresh stamp: must not share eventId with the tool lifecycle event.
+ if (ctx.planModeActive) {
+ const planMarkdown = extractGrokPlanMarkdownFromToolCallData(
+ event.toolCall.data,
+ grokPlanPathHost,
+ );
+ if (planMarkdown !== undefined) {
+ yield* emitProposedPlanCompleted(
+ ctx,
+ notificationTurnId,
+ yield* makeEventStamp(),
+ planMarkdown,
+ {
+ method: "session/update",
+ payload: event.rawPayload,
+ },
+ );
+ }
+ }
return;
+ }
case "ContentDelta":
yield* offerRuntimeEvent(
makeAcpContentDeltaEvent({
@@ -892,6 +1436,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
ctx.notificationFiber = nf;
sessions.set(input.threadId, ctx);
+ yield* runTurnLivenessWatchdog(ctx).pipe(Effect.forkIn(ctx.scope), Effect.asVoid);
sessionScopeTransferred = true;
yield* offerRuntimeEvent({
@@ -938,6 +1483,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
// Bind the turn id before cooperative yields so interruptTurn can
// settle this prompt even if stop arrives during preparation.
ctx.activeTurnId = turnId;
+ // New turn: do not fall back to a previous turn's plan.md body when
+ // exit_plan_mode omits planContent.
+ if (steeringTurnId === undefined) {
+ clearProposedPlanFallback(ctx);
+ }
ctx.session = {
...ctx.session,
status: steeringTurnId === undefined ? "connecting" : "running",
@@ -953,17 +1503,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
const requestedTurnModelId = turnModelSelection?.model
? resolveGrokAcpBaseModelId(turnModelSelection.model)
: undefined;
- const currentModelId = yield* applyGrokAcpModelSelection({
- runtime: ctx.acp,
- currentModelId: ctx.currentModelId,
- requestedModelId: requestedTurnModelId,
- mapError: (cause) =>
- mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
- });
+ const requestedTurnReasoningEffort = getModelSelectionStringOptionValue(
+ turnModelSelection,
+ "reasoningEffort",
+ );
const text = input.input?.trim();
+ // Grok ingests images only. Generic files reach the agent
+ // through the path line ProviderService puts in the prompt.
const imagePromptParts = yield* Effect.forEach(
- input.attachments ?? [],
+ (input.attachments ?? []).filter((attachment) => attachment.type === "image"),
(attachment) =>
Effect.gen(function* () {
const attachmentPath = resolveAttachmentPath({
@@ -1008,7 +1557,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
});
}
+ const currentModelId = yield* applyGrokAcpModelSelection({
+ runtime: ctx.acp,
+ currentModelId: ctx.currentModelId,
+ currentReasoningEffort: ctx.currentReasoningEffort,
+ requestedModelId: requestedTurnModelId,
+ requestedReasoningEffort: requestedTurnReasoningEffort,
+ mapError: (cause) =>
+ mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
+ });
ctx.currentModelId = currentModelId;
+ if (requestedTurnReasoningEffort !== undefined) {
+ ctx.currentReasoningEffort = normalizeGrokReasoningEffort(
+ requestedTurnReasoningEffort,
+ );
+ }
const displayModel = currentModelId
? resolveGrokAcpBaseModelId(currentModelId)
: undefined;
@@ -1037,6 +1600,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
updatedAt: yield* nowIso,
...(displayModel ? { model: displayModel } : {}),
};
+ if (steeringTurnId === undefined) {
+ yield* beginTurnLiveness(ctx, turnId);
+ } else {
+ yield* refreshSessionTurnLiveness(input.threadId, turnId);
+ }
if (steeringTurnId === undefined) {
yield* offerRuntimeEvent({
@@ -1087,10 +1655,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
})
.pipe(
Effect.tap((promptResult) =>
- Effect.all([
- Ref.set(promptRpcSucceeded, true),
- Ref.set(promptResultRef, promptResult),
- ]),
+ Effect.all(
+ [
+ Ref.set(promptRpcSucceeded, true),
+ Ref.set(promptResultRef, promptResult),
+ markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId),
+ ],
+ { discard: true },
+ ),
),
Effect.tapError((error) =>
Ref.set(
@@ -1131,6 +1703,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
yield* Effect.yieldNow;
}
yield* prepared.acp.drainEvents;
+ consumePromptResponseReady(ctx);
if (ctx.interruptedTurnIds.has(prepared.turnId)) {
yield* Ref.set(promptSettled, true);
return {
@@ -1189,6 +1762,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
updatedAt: completedAt,
...(prepared.displayModel ? { model: prepared.displayModel } : {}),
};
+ yield* clearTurnLiveness(ctx);
const completedStopReason = completedStopReasonFromPromptResponse(result);
yield* offerRuntimeEvent({
type: "turn.completed",
@@ -1245,6 +1819,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
if (ctx.interruptedTurnIds.has(prepared.turnId)) {
return;
}
+ consumePromptResponseReady(ctx);
if (
ctx.promptsInFlight <= 0 ||
ctx.activeTurnId !== prepared.turnId ||
@@ -1361,6 +1936,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
status: "ready",
updatedAt,
};
+ yield* clearTurnLiveness(ctx);
}
}),
);
diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts
index 1c9bf1f26d..42d642a3e7 100644
--- a/apps/server/src/provider/Layers/GrokProvider.test.ts
+++ b/apps/server/src/provider/Layers/GrokProvider.test.ts
@@ -6,10 +6,183 @@ import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { GrokSettings } from "@t3tools/contracts";
-import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts";
+import {
+ buildGrokModelCapabilities,
+ buildInitialGrokProviderSnapshot,
+ checkGrokProviderStatus,
+} from "./GrokProvider.ts";
const decodeGrokSettings = Schema.decodeSync(GrokSettings);
+describe("buildGrokModelCapabilities", () => {
+ it("preserves ACP-provided reasoning labels and the active default", () => {
+ const capabilities = buildGrokModelCapabilities({
+ modelId: "grok-4.6",
+ name: "Grok 4.6",
+ _meta: {
+ supportsReasoningEffort: true,
+ reasoningEffort: "xhigh",
+ reasoningEfforts: [
+ { value: "xhigh", label: "Extra High Effort", default: true },
+ { value: "high", label: "High Effort", default: true },
+ { value: "medium", label: "Medium Effort" },
+ { value: "low", label: "Low Effort" },
+ ],
+ },
+ });
+
+ expect(capabilities.optionDescriptors).toEqual([
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ currentValue: "xhigh",
+ options: [
+ { id: "xhigh", label: "Extra High Effort", isDefault: true },
+ { id: "high", label: "High Effort" },
+ { id: "medium", label: "Medium Effort" },
+ { id: "low", label: "Low Effort" },
+ ],
+ },
+ ]);
+ });
+
+ it("uses raw ACP values when option labels are omitted", () => {
+ const capabilities = buildGrokModelCapabilities({
+ modelId: "grok-4.6",
+ name: "Grok 4.6",
+ _meta: {
+ supportsReasoningEffort: true,
+ reasoningEffort: "xhigh",
+ reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }],
+ },
+ });
+
+ expect(capabilities.optionDescriptors).toEqual([
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ currentValue: "xhigh",
+ options: [
+ { id: "xhigh", label: "xhigh" },
+ { id: "medium", label: "medium" },
+ ],
+ },
+ ]);
+ });
+
+ it("keeps ACP current effort separate from its collapsed advertised default", () => {
+ const capabilities = buildGrokModelCapabilities({
+ modelId: "grok-4.6",
+ name: "Grok 4.6",
+ _meta: {
+ supportsReasoningEffort: true,
+ reasoningEffort: "medium",
+ reasoningEfforts: [
+ { value: "xhigh", label: "Extra High Effort", default: true },
+ { value: "high", label: "High Effort", default: true },
+ { value: "medium", label: "Medium Effort" },
+ ],
+ },
+ });
+
+ expect(capabilities.optionDescriptors).toEqual([
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ currentValue: "medium",
+ options: [
+ { id: "xhigh", label: "Extra High Effort", isDefault: true },
+ { id: "high", label: "High Effort" },
+ { id: "medium", label: "Medium Effort" },
+ ],
+ },
+ ]);
+ });
+
+ it("preserves ACP descriptions and falls back from invalid values to valid ids", () => {
+ const capabilities = buildGrokModelCapabilities({
+ modelId: "grok-4.6",
+ name: "Grok 4.6",
+ _meta: {
+ supportsReasoningEffort: true,
+ reasoningEffort: "high",
+ reasoningEfforts: [
+ {
+ id: "high",
+ value: "not a token",
+ label: "High Effort",
+ description: "Higher implementation quality",
+ default: true,
+ },
+ { id: "bad id", value: "also invalid", label: "Invalid" },
+ ],
+ },
+ });
+
+ expect(capabilities.optionDescriptors).toEqual([
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ currentValue: "high",
+ options: [
+ {
+ id: "high",
+ label: "High Effort",
+ description: "Higher implementation quality",
+ isDefault: true,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("accepts an advertised ACP menu when the support flag is omitted", () => {
+ const capabilities = buildGrokModelCapabilities({
+ modelId: "grok-4.6",
+ name: "Grok 4.6",
+ _meta: {
+ reasoningEffort: "high",
+ reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
+ },
+ });
+
+ expect(capabilities.optionDescriptors).toHaveLength(1);
+ });
+
+ it("honors an explicit ACP opt-out even when a menu is present", () => {
+ const capabilities = buildGrokModelCapabilities({
+ modelId: "grok-4.6",
+ name: "Grok 4.6",
+ _meta: {
+ supportsReasoningEffort: false,
+ reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
+ },
+ });
+
+ expect(capabilities.optionDescriptors).toEqual([]);
+ });
+
+ it("does not synthesize a reasoning menu when ACP omits it", () => {
+ expect(
+ buildGrokModelCapabilities({
+ modelId: "grok-4.6",
+ name: "Grok 4.6",
+ _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" },
+ }).optionDescriptors,
+ ).toEqual([]);
+ });
+
+ it("keeps non-reasoning Grok models free of reasoning controls", () => {
+ expect(
+ buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors,
+ ).toEqual([]);
+ });
+});
+
describe("buildInitialGrokProviderSnapshot", () => {
it.effect("returns a disabled snapshot when settings.enabled is false", () =>
Effect.gen(function* () {
diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts
index 934eecdb5a..44f9369b82 100644
--- a/apps/server/src/provider/Layers/GrokProvider.ts
+++ b/apps/server/src/provider/Layers/GrokProvider.ts
@@ -29,7 +29,12 @@ import {
enrichProviderSnapshotWithVersionAdvisory,
type ProviderMaintenanceCapabilities,
} from "../providerMaintenance.ts";
-import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts";
+import {
+ isValidGrokReasoningEffortToken,
+ makeGrokAcpRuntime,
+ resolveGrokAcpBaseModelId,
+} from "../acp/GrokAcpSupport.ts";
+import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";
const GROK_PRESENTATION = {
displayName: "Grok",
@@ -99,6 +104,104 @@ function grokModelsFromSettings(
return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
}
+function nonEmptyString(value: unknown): string | undefined {
+ return typeof value === "string" ? value.trim() || undefined : undefined;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
+ readonly options: ReadonlyArray<{
+ value: string;
+ label: string;
+ description?: string;
+ isDefault?: boolean;
+ }>;
+ readonly currentValue: string | undefined;
+} {
+ const meta = model._meta;
+ if (!meta || meta.supportsReasoningEffort === false) {
+ return { options: [], currentValue: undefined };
+ }
+
+ const currentEffort = nonEmptyString(meta.reasoningEffort);
+ const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
+ const seen = new Set();
+ const options: Array<{
+ value: string;
+ label: string;
+ description?: string;
+ advertisedDefault: boolean;
+ }> = [];
+
+ for (const entry of advertisedOptions) {
+ if (!isRecord(entry)) {
+ continue;
+ }
+ const rawValue = nonEmptyString(entry.value);
+ const rawId = nonEmptyString(entry.id);
+ const value =
+ rawValue && isValidGrokReasoningEffortToken(rawValue)
+ ? rawValue
+ : rawId && isValidGrokReasoningEffortToken(rawId)
+ ? rawId
+ : undefined;
+ if (value === undefined || seen.has(value)) {
+ continue;
+ }
+ seen.add(value);
+ const description = nonEmptyString(entry.description);
+ options.push({
+ value,
+ label: nonEmptyString(entry.label) ?? value,
+ ...(description ? { description } : {}),
+ advertisedDefault: entry.default === true || entry.isDefault === true,
+ });
+ }
+
+ const currentValue =
+ currentEffort && options.some((option) => option.value === currentEffort)
+ ? currentEffort
+ : undefined;
+ const advertisedDefaults = options.filter((option) => option.advertisedDefault);
+ const selectedDefault =
+ advertisedDefaults.find((option) => option.value === currentValue)?.value ??
+ advertisedDefaults[0]?.value;
+ return {
+ options: options.map(({ value, label, description }) => ({
+ value,
+ label,
+ ...(description ? { description } : {}),
+ ...(value === selectedDefault ? { isDefault: true } : {}),
+ })),
+ currentValue: currentValue ?? selectedDefault,
+ };
+}
+
+export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
+ const reasoning = grokReasoningOptionsFromModel(model);
+ return reasoning.options.length > 0
+ ? createModelCapabilities({
+ optionDescriptors: [
+ {
+ id: "reasoningEffort",
+ label: "Reasoning",
+ type: "select",
+ options: reasoning.options.map((option) => ({
+ id: option.value,
+ label: option.label,
+ ...(option.description ? { description: option.description } : {}),
+ ...(option.isDefault ? { isDefault: true } : {}),
+ })),
+ ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
+ },
+ ],
+ })
+ : EMPTY_CAPABILITIES;
+}
+
function buildGrokDiscoveredModelsFromSessionModelState(
modelState: EffectAcpSchema.SessionModelState | null | undefined,
): ReadonlyArray {
@@ -117,7 +220,7 @@ function buildGrokDiscoveredModelsFromSessionModelState(
slug,
name: model.name.trim() || slug,
isCustom: false,
- capabilities: EMPTY_CAPABILITIES,
+ capabilities: buildGrokModelCapabilities(model),
};
})
.filter((model): model is ServerProviderModel => model !== undefined);
@@ -161,6 +264,7 @@ const runGrokVersionCommand = (
export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
grokSettings: GrokSettings,
environment: NodeJS.ProcessEnv = process.env,
+ cwd?: string,
): Effect.fn.Return<
ServerProviderDraft,
never,
@@ -251,6 +355,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
});
}
+ const skills = yield* discoverGrokSkills(grokSettings, environment, cwd);
+
const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(
Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
Effect.exit,
@@ -264,6 +370,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
enabled: grokSettings.enabled,
checkedAt,
models: fallbackModels,
+ skills,
probe: {
installed: true,
version,
@@ -282,6 +389,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
enabled: grokSettings.enabled,
checkedAt,
models: fallbackModels,
+ skills,
probe: {
installed: true,
version,
@@ -302,6 +410,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
enabled: grokSettings.enabled,
checkedAt,
models,
+ skills,
probe: {
installed: true,
version,
diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
index eea328e05d..9823a68708 100644
--- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
+++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
@@ -1,6 +1,7 @@
import * as NodeAssert from "node:assert/strict";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
+import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
@@ -14,8 +15,10 @@ import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import { beforeEach } from "vite-plus/test";
+import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2";
import {
+ ApprovalRequestId,
OpenCodeSettings,
ProviderDriverKind,
ProviderInstanceId,
@@ -59,19 +62,48 @@ const runtimeMock = {
startCalls: [] as string[],
sessionCreateUrls: [] as string[],
sessionCreateInputs: [] as Array>,
+ createdSessionIds: [] as string[],
authHeaders: [] as Array,
abortCalls: [] as string[],
+ abortSignals: [] as AbortSignal[],
+ abortImplementation: null as
+ | ((sessionID: string, signal?: AbortSignal) => Promise)
+ | null,
closeCalls: [] as string[],
revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
+ messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
+ messageFailures: 0,
promptCalls: [] as Array,
promptAsyncError: null as Error | null,
+ promptAsyncImplementation: null as (() => Promise) | null,
+ autoPromptEcho: true,
+ autoConnect: true,
+ promptEchoEvents: [] as Array,
closeError: null as Error | null,
messages: [] as MessageEntry[],
- subscribedEvents: [] as unknown[],
+ subscribedEvents: [] as Array>,
+ eventSubscribeObserved: null as (() => void) | null,
+ permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>,
+ questionReplyCalls: [] as Array<{
+ requestID: string;
+ answers: ReadonlyArray>;
+ }>,
+ sessionStatus: "idle" as "idle" | "busy",
+ sessionStatusFailures: 0,
+ sessionStatusCalls: 0,
+ sessionStatusImplementation: null as (() => Promise) | null,
sessionGetIds: [] as string[],
+ sessionGetObserved: null as ((sessionID: string) => void) | null,
missingSessionIds: new Set(),
transientErrorSessionIds: new Set(),
sessionDirectoryById: new Map(),
+ sessionParentById: new Map(),
+ pendingPermissions: [] as Array,
+ pendingQuestions: [] as Array,
+ permissionListCalls: 0,
+ questionListCalls: 0,
+ permissionListImplementation: null as (() => Promise>) | null,
+ questionListImplementation: null as (() => Promise>) | null,
sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>,
forkCalls: [] as Array<{ sessionID: string; directory?: string }>,
},
@@ -79,26 +111,50 @@ const runtimeMock = {
this.state.startCalls.length = 0;
this.state.sessionCreateUrls.length = 0;
this.state.sessionCreateInputs.length = 0;
+ this.state.createdSessionIds.length = 0;
this.state.authHeaders.length = 0;
this.state.abortCalls.length = 0;
+ this.state.abortSignals.length = 0;
+ this.state.abortImplementation = null;
this.state.closeCalls.length = 0;
this.state.revertCalls.length = 0;
+ this.state.messageCalls.length = 0;
+ this.state.messageFailures = 0;
this.state.promptCalls.length = 0;
this.state.promptAsyncError = null;
+ this.state.promptAsyncImplementation = null;
+ this.state.autoPromptEcho = true;
+ this.state.autoConnect = true;
+ this.state.promptEchoEvents.length = 0;
this.state.closeError = null;
this.state.messages = [];
this.state.subscribedEvents = [];
+ this.state.eventSubscribeObserved = null;
+ this.state.permissionReplyCalls.length = 0;
+ this.state.questionReplyCalls.length = 0;
+ this.state.sessionStatus = "idle";
+ this.state.sessionStatusFailures = 0;
+ this.state.sessionStatusCalls = 0;
+ this.state.sessionStatusImplementation = null;
this.state.sessionGetIds.length = 0;
+ this.state.sessionGetObserved = null;
this.state.missingSessionIds.clear();
this.state.transientErrorSessionIds.clear();
this.state.sessionDirectoryById.clear();
+ this.state.sessionParentById.clear();
+ this.state.pendingPermissions = [];
+ this.state.pendingQuestions = [];
+ this.state.permissionListCalls = 0;
+ this.state.questionListCalls = 0;
+ this.state.permissionListImplementation = null;
+ this.state.questionListImplementation = null;
this.state.sessionUpdateCalls.length = 0;
this.state.forkCalls.length = 0;
},
};
const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
- startOpenCodeServerProcess: ({ binaryPath }) =>
+ startOpenCodeServerProcess: ({ binaryPath, serverPassword }) =>
Effect.gen(function* () {
runtimeMock.state.startCalls.push(binaryPath);
const url = "http://127.0.0.1:4301";
@@ -112,10 +168,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
);
return {
url,
+ version: "1.15.13",
+ ...(serverPassword ? { serverPassword } : {}),
exitCode: Effect.never,
+ isRunning: Effect.succeed(true),
};
}),
- connectToOpenCodeServer: ({ serverUrl }) =>
+ connectToOpenCodeServer: ({ serverUrl, serverPassword }) =>
Effect.gen(function* () {
const url = serverUrl ?? "http://127.0.0.1:4301";
// Always register a finalizer so the closeCalls/closeError probes fire;
@@ -130,6 +189,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
);
return {
url,
+ version: "1.15.13",
+ ...(serverPassword ? { serverPassword } : {}),
exitCode: null,
external: Boolean(serverUrl),
};
@@ -144,10 +205,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
runtimeMock.state.authHeaders.push(
serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
);
- return { data: { id: `${baseUrl}/session` } };
+ return {
+ data: { id: runtimeMock.state.createdSessionIds.shift() ?? `${baseUrl}/session` },
+ };
},
get: async ({ sessionID }: { sessionID: string }) => {
runtimeMock.state.sessionGetIds.push(sessionID);
+ runtimeMock.state.sessionGetObserved?.(sessionID);
// The real client is `throwOnError: true`: non-2xx rejects rather
// than resolving, so missing → 404 throw, transient → 500 throw.
if (runtimeMock.state.transientErrorSessionIds.has(sessionID)) {
@@ -159,7 +223,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
});
}
const directory = runtimeMock.state.sessionDirectoryById.get(sessionID);
- return { data: { id: sessionID, ...(directory ? { directory } : {}) } };
+ const parentID = runtimeMock.state.sessionParentById.get(sessionID);
+ return {
+ data: {
+ id: sessionID,
+ ...(directory ? { directory } : {}),
+ ...(parentID ? { parentID } : {}),
+ },
+ };
},
update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => {
runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission });
@@ -174,16 +245,73 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
}
return { data: { id: forkedId, ...(directory ? { directory } : {}) } };
},
- abort: async ({ sessionID }: { sessionID: string }) => {
+ abort: async ({ sessionID }: { sessionID: string }, options?: { signal?: AbortSignal }) => {
runtimeMock.state.abortCalls.push(sessionID);
+ if (options?.signal) {
+ runtimeMock.state.abortSignals.push(options.signal);
+ }
+ await runtimeMock.state.abortImplementation?.(sessionID, options?.signal);
+ },
+ status: async () => {
+ runtimeMock.state.sessionStatusCalls += 1;
+ if (runtimeMock.state.sessionStatusImplementation) {
+ return await runtimeMock.state.sessionStatusImplementation();
+ }
+ if (runtimeMock.state.sessionStatusFailures > 0) {
+ runtimeMock.state.sessionStatusFailures -= 1;
+ throw new Error("status failed");
+ }
+ return {
+ data:
+ runtimeMock.state.sessionStatus === "idle"
+ ? {}
+ : { "http://127.0.0.1:9999/session": { type: "busy" as const } },
+ };
},
promptAsync: async (input: unknown) => {
runtimeMock.state.promptCalls.push(input);
+ await runtimeMock.state.promptAsyncImplementation?.();
if (runtimeMock.state.promptAsyncError) {
throw runtimeMock.state.promptAsyncError;
}
+ if (
+ runtimeMock.state.autoPromptEcho &&
+ typeof input === "object" &&
+ input !== null &&
+ "sessionID" in input &&
+ "messageID" in input &&
+ typeof input.sessionID === "string" &&
+ typeof input.messageID === "string"
+ ) {
+ runtimeMock.state.messages.push({
+ info: { id: input.messageID, role: "user" },
+ parts: [],
+ });
+ runtimeMock.state.promptEchoEvents.push({
+ id: `evt-auto-user-${input.messageID}`,
+ type: "message.updated",
+ properties: {
+ sessionID: input.sessionID,
+ info: { id: input.messageID, role: "user" },
+ },
+ });
+ }
},
messages: async () => ({ data: runtimeMock.state.messages }),
+ message: async ({ sessionID, messageID }: { sessionID: string; messageID: string }) => {
+ runtimeMock.state.messageCalls.push({ sessionID, messageID });
+ if (runtimeMock.state.messageFailures > 0) {
+ runtimeMock.state.messageFailures -= 1;
+ throw new Error("message lookup failed", { cause: { status: 500 } });
+ }
+ const message = runtimeMock.state.messages.find((entry) => entry.info.id === messageID);
+ if (!message) {
+ throw new Error(`Message not found: ${messageID}`, {
+ cause: { status: 404, body: { name: "NotFoundError" } },
+ });
+ }
+ return { data: message };
+ },
revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => {
runtimeMock.state.revertCalls.push({
sessionID,
@@ -204,13 +332,55 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
},
},
event: {
- subscribe: async () => ({
- stream: (async function* () {
- for (const event of runtimeMock.state.subscribedEvents) {
- yield event;
- }
- })(),
- }),
+ subscribe: async () => {
+ runtimeMock.state.eventSubscribeObserved?.();
+ return {
+ stream: (async function* () {
+ if (runtimeMock.state.autoConnect) {
+ yield { id: "evt-auto-connected", type: "server.connected", properties: {} };
+ }
+ for (const event of runtimeMock.state.subscribedEvents) {
+ const resolved = await event;
+ while (runtimeMock.state.promptEchoEvents.length > 0) {
+ yield runtimeMock.state.promptEchoEvents.shift();
+ }
+ yield resolved;
+ }
+ })(),
+ };
+ },
+ },
+ permission: {
+ list: async () => {
+ runtimeMock.state.permissionListCalls += 1;
+ return {
+ data: runtimeMock.state.permissionListImplementation
+ ? await runtimeMock.state.permissionListImplementation()
+ : runtimeMock.state.pendingPermissions,
+ };
+ },
+ reply: async ({ requestID, reply }: { requestID: string; reply: string }) => {
+ runtimeMock.state.permissionReplyCalls.push({ requestID, reply });
+ },
+ },
+ question: {
+ list: async () => {
+ runtimeMock.state.questionListCalls += 1;
+ return {
+ data: runtimeMock.state.questionListImplementation
+ ? await runtimeMock.state.questionListImplementation()
+ : runtimeMock.state.pendingQuestions,
+ };
+ },
+ reply: async ({
+ requestID,
+ answers,
+ }: {
+ requestID: string;
+ answers: ReadonlyArray>;
+ }) => {
+ runtimeMock.state.questionReplyCalls.push({ requestID, answers });
+ },
},
}) as unknown as ReturnType,
loadOpenCodeInventory: () =>
@@ -280,6 +450,37 @@ beforeEach(() => {
const advanceTestClock = (ms: number) =>
TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow));
+function promiseWithResolvers() {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({
+ id,
+ sessionID,
+ permission: "bash",
+ patterns: ["pwd"],
+ metadata: {},
+ always: [],
+});
+
+const questionRequest = (id: string, sessionID: string): QuestionRequest => ({
+ id,
+ sessionID,
+ questions: [
+ {
+ header: "Scope",
+ question: "Which scope should OpenCode use?",
+ options: [{ label: "Workspace", description: "Use this workspace." }],
+ },
+ ],
+});
+
it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () =>
Effect.gen(function* () {
@@ -301,6 +502,348 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
}),
);
+ it.effect("fails startup when the OpenCode event stream does not connect", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-connect-timeout");
+ runtimeMock.state.autoConnect = false;
+
+ const startFiber = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.result, Effect.forkChild);
+ yield* Effect.yieldNow;
+ yield* advanceTestClock(10_000);
+
+ const result = yield* Fiber.join(startFiber);
+ NodeAssert.equal(result._tag, "Failure");
+ NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError");
+ NodeAssert.equal(result.failure.method, "event.subscribe");
+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
+ }),
+ );
+
+ it.effect("closes a connecting session when startup is interrupted", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-connect-interrupted");
+ const eventSubscribeObserved = promiseWithResolvers();
+ runtimeMock.state.autoConnect = false;
+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
+
+ const startFiber = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.forkChild);
+ yield* Effect.promise(() => eventSubscribeObserved.promise);
+ yield* Effect.yieldNow;
+ yield* Fiber.interrupt(startFiber);
+
+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
+ NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["http://127.0.0.1:9999/session"]);
+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
+ }),
+ );
+
+ it.effect("stops a connecting session and rejects its waiting send", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-stop-connecting");
+ const eventSubscribeObserved = promiseWithResolvers();
+ runtimeMock.state.autoConnect = false;
+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
+
+ const startFiber = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.result, Effect.forkChild);
+ yield* Effect.promise(() => eventSubscribeObserved.promise);
+ const connecting = (yield* adapter.listSessions()).find(
+ (session) => session.threadId === threadId,
+ );
+ NodeAssert.equal(connecting?.status, "connecting");
+
+ const sendFiber = yield* adapter
+ .sendTurn({
+ threadId,
+ input: "Must not be sent",
+ modelSelection: createModelSelection(
+ ProviderInstanceId.make("opencode"),
+ "opencode/kimi-k3",
+ ),
+ })
+ .pipe(Effect.exit, Effect.forkChild);
+ yield* Effect.yieldNow;
+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 0);
+
+ yield* adapter.stopSession(threadId);
+ const startResult = yield* Fiber.join(startFiber);
+ const sendResult = yield* Fiber.join(sendFiber);
+ NodeAssert.equal(startResult._tag, "Failure");
+ NodeAssert.equal(sendResult._tag, "Failure");
+ NodeAssert.equal(runtimeMock.state.promptCalls.length, 0);
+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
+ }),
+ );
+
+ it.effect("aborts a held teardown request before closing the session scope", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-teardown-timeout");
+ const abortStarted = promiseWithResolvers();
+ runtimeMock.state.abortImplementation = async () => {
+ abortStarted.resolve(undefined);
+ await new Promise(() => {});
+ };
+ yield* adapter.startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ });
+ const stopFiber = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
+ yield* Effect.promise(() => abortStarted.promise);
+
+ yield* advanceTestClock(999);
+ NodeAssert.equal(stopFiber.pollUnsafe(), undefined);
+ NodeAssert.equal(runtimeMock.state.abortSignals.length, 1);
+ NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, false);
+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, []);
+
+ yield* advanceTestClock(1);
+ yield* Fiber.join(stopFiber);
+ NodeAssert.equal(runtimeMock.state.abortSignals[0]?.aborted, true);
+ NodeAssert.deepEqual(runtimeMock.state.closeCalls, ["http://127.0.0.1:9999"]);
+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
+ }),
+ );
+
+ it.effect("stopAll closes a connecting session and releases startup", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-stop-all-connecting");
+ const eventSubscribeObserved = promiseWithResolvers();
+ runtimeMock.state.autoConnect = false;
+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
+
+ const startFiber = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.result, Effect.forkChild);
+ yield* Effect.promise(() => eventSubscribeObserved.promise);
+ const sessionCount = (yield* adapter.listSessions()).length;
+
+ yield* adapter.stopAll();
+ const startResult = yield* Fiber.join(startFiber);
+ NodeAssert.equal(startResult._tag, "Failure");
+ NodeAssert.equal(runtimeMock.state.closeCalls.length, sessionCount);
+ NodeAssert.equal(yield* adapter.hasSession(threadId), false);
+ }),
+ );
+
+ it.effect("keeps one session when concurrent starts cross the connection barrier", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-concurrent-start");
+ const connectionEvent = promiseWithResolvers();
+ runtimeMock.state.autoConnect = false;
+ runtimeMock.state.createdSessionIds.push("ses_race_a", "ses_race_b");
+ runtimeMock.state.subscribedEvents = [connectionEvent.promise];
+
+ const firstStart = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.forkChild);
+ const secondStart = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.forkChild);
+ yield* Effect.yieldNow;
+ connectionEvent.resolve({
+ id: "evt-concurrent-start-connected",
+ type: "server.connected",
+ properties: {},
+ });
+
+ const [firstSession, secondSession] = yield* Effect.all([
+ Fiber.join(firstStart),
+ Fiber.join(secondStart),
+ ]);
+ const sessions = yield* adapter.listSessions();
+ const threadSessions = sessions.filter((session) => session.threadId === threadId);
+ NodeAssert.equal(threadSessions.length, 1);
+ NodeAssert.deepEqual(firstSession.resumeCursor, secondSession.resumeCursor);
+ NodeAssert.equal(firstSession.status, "ready");
+ NodeAssert.equal(secondSession.status, "ready");
+ const winnerId = (threadSessions[0]?.resumeCursor as { sessionId?: string } | undefined)
+ ?.sessionId;
+ NodeAssert.ok(winnerId === "ses_race_a" || winnerId === "ses_race_b");
+ NodeAssert.deepEqual(runtimeMock.state.abortCalls, [
+ winnerId === "ses_race_a" ? "ses_race_b" : "ses_race_a",
+ ]);
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("reuses a published connecting session after it becomes ready", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-reuse-connecting");
+ const connectionEvent = promiseWithResolvers();
+ const eventSubscribeObserved = promiseWithResolvers();
+ runtimeMock.state.autoConnect = false;
+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
+ runtimeMock.state.subscribedEvents = [connectionEvent.promise];
+
+ const owningStart = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.forkChild);
+ yield* Effect.promise(() => eventSubscribeObserved.promise);
+ const reusedStart = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.forkChild);
+ yield* Effect.yieldNow;
+ NodeAssert.equal(runtimeMock.state.sessionCreateUrls.length, 1);
+
+ connectionEvent.resolve({
+ id: "evt-reused-start-connected",
+ type: "server.connected",
+ properties: {},
+ });
+ const [ownedSession, reusedSession] = yield* Effect.all([
+ Fiber.join(owningStart),
+ Fiber.join(reusedStart),
+ ]);
+ NodeAssert.equal(ownedSession.status, "ready");
+ NodeAssert.equal(reusedSession.status, "ready");
+ NodeAssert.deepEqual(ownedSession.resumeCursor, reusedSession.resumeCursor);
+
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("does not let an old held stop delete its replacement", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-old-stop-replacement");
+ const abortStarted = promiseWithResolvers();
+ const abortRelease = promiseWithResolvers();
+ runtimeMock.state.createdSessionIds.push("ses_old", "ses_replacement");
+
+ const oldSession = yield* adapter.startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ });
+ runtimeMock.state.abortImplementation = async () => {
+ abortStarted.resolve(undefined);
+ await abortRelease.promise;
+ };
+ const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
+ yield* Effect.promise(() => abortStarted.promise);
+
+ const replacement = yield* adapter.startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ });
+ NodeAssert.deepEqual(oldSession.resumeCursor, { schemaVersion: 1, sessionId: "ses_old" });
+ NodeAssert.deepEqual(replacement.resumeCursor, {
+ schemaVersion: 1,
+ sessionId: "ses_replacement",
+ });
+
+ abortRelease.resolve(undefined);
+ yield* Fiber.join(oldStop);
+ const current = (yield* adapter.listSessions()).find(
+ (session) => session.threadId === threadId,
+ );
+ NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor);
+
+ runtimeMock.state.abortImplementation = null;
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
+ it.effect("replaces a stopped connecting session while its teardown is held", () =>
+ Effect.gen(function* () {
+ const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-opencode-stopped-connecting-retry");
+ const eventSubscribeObserved = promiseWithResolvers();
+ const abortStarted = promiseWithResolvers();
+ const abortRelease = promiseWithResolvers();
+ runtimeMock.state.autoConnect = false;
+ runtimeMock.state.eventSubscribeObserved = () => eventSubscribeObserved.resolve(undefined);
+ runtimeMock.state.createdSessionIds.push("ses_connecting_old", "ses_connecting_replacement");
+
+ const oldStart = yield* adapter
+ .startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ })
+ .pipe(Effect.result, Effect.forkChild);
+ yield* Effect.promise(() => eventSubscribeObserved.promise);
+
+ runtimeMock.state.abortImplementation = async () => {
+ abortStarted.resolve(undefined);
+ await abortRelease.promise;
+ };
+ const oldStop = yield* adapter.stopSession(threadId).pipe(Effect.forkChild);
+ yield* Effect.promise(() => abortStarted.promise);
+
+ runtimeMock.state.autoConnect = true;
+ runtimeMock.state.abortImplementation = null;
+ const replacement = yield* adapter.startSession({
+ provider: ProviderDriverKind.make("opencode"),
+ threadId,
+ runtimeMode: "full-access",
+ });
+ NodeAssert.equal(replacement.status, "ready");
+ NodeAssert.deepEqual(replacement.resumeCursor, {
+ schemaVersion: 1,
+ sessionId: "ses_connecting_replacement",
+ });
+
+ abortRelease.resolve(undefined);
+ const oldStartResult = yield* Fiber.join(oldStart);
+ yield* Fiber.join(oldStop);
+ NodeAssert.equal(oldStartResult._tag, "Failure");
+ const current = (yield* adapter.listSessions()).find(
+ (session) => session.threadId === threadId,
+ );
+ NodeAssert.deepEqual(current?.resumeCursor, replacement.resumeCursor);
+
+ yield* adapter.stopSession(threadId);
+ }),
+ );
+
it.effect("returns a durable resume cursor for a freshly created session", () =>
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
@@ -809,95 +1352,3099 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
}),
);
- it.effect("passes agent and variant options for the adapter's bound custom instance id", () => {
- const instanceId = ProviderInstanceId.make("opencode_zen");
- const adapterLayer = Layer.effect(
- OpenCodeAdapter,
- makeOpenCodeAdapter(openCodeAdapterTestSettings, { instanceId }),
- ).pipe(
- Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)),
- Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
- Layer.provideMerge(ServerSettingsService.layerTest()),
- Layer.provideMerge(providerSessionDirectoryTestLayer),
- Layer.provideMerge(NodeServices.layer),
- );
-
- return Effect.gen(function* () {
+ it.effect("does not let an old idle status complete a successful steer", () =>
+ Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
+ const threadId = asThreadId("thread-steer-idle-admission");
+ const busyBeforeSteer = promiseWithResolvers();
+ const idleBeforeSteer = promiseWithResolvers();
+ const idleAfterSteer = promiseWithResolvers