From 8731a2b5a367ba08340f7d631bd018772b74e134 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:12:39 +0200 Subject: [PATCH 01/99] perf(desktop): speed up Windows update installation (#6169) (cherry picked from commit c9063f03ea1c16e0239e1996a9b6ef611679995d) --- .../src/app/DesktopEnvironment.test.ts | 19 + apps/desktop/src/app/DesktopEnvironment.ts | 14 +- .../DesktopBackendConfiguration.test.ts | 104 ++- .../backend/DesktopBackendConfiguration.ts | 52 +- apps/desktop/src/main.ts | 2 + .../src/wsl/DesktopWslServerTree.test.ts | 323 ++++++++ apps/desktop/src/wsl/DesktopWslServerTree.ts | 226 ++++++ apps/server/package.json | 1 + docs/operations/release.md | 31 + patches/@ff-labs__fff-node@0.9.4.patch | 10 +- pnpm-lock.yaml | 14 +- scripts/build-desktop-artifact.test.ts | 407 +++++++++- scripts/build-desktop-artifact.ts | 707 +++++++++++++++--- scripts/lib/cli-external-packages.test.ts | 53 +- scripts/lib/cli-external-packages.ts | 46 +- scripts/package.json | 1 + 16 files changed, 1822 insertions(+), 188 deletions(-) create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.test.ts create mode 100644 apps/desktop/src/wsl/DesktopWslServerTree.ts diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index d6ff92a66..a4f37988d 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -65,6 +65,7 @@ describe("DesktopEnvironment", () => { assert.equal(environment.browserArtifactsDir, "/tmp/t3/userdata/browser-artifacts"); assert.equal(environment.rootDir, "/repo"); assert.equal(environment.appRoot, "/repo"); + assert.equal(environment.serverRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); assert.equal(environment.appUserModelId, "com.pylon.code.dev"); @@ -108,6 +109,24 @@ describe("DesktopEnvironment", () => { }), ); + it.effect("uses the packaged Windows server sidecar as the backend root", () => + Effect.gen(function* () { + const environment = yield* makeEnvironment({ + platform: "win32", + isPackaged: true, + appPath: "/install/resources/app.asar", + resourcesPath: "/install/resources", + }); + + assert.equal(environment.appRoot, "/install/resources/app.asar"); + assert.equal(environment.serverRoot, "/install/resources/server.asar"); + assert.equal( + environment.backendEntryPath, + "/install/resources/server.asar/apps/server/dist/bin.mjs", + ); + }), + ); + it.effect("keeps implicit development state separate from production state", () => Effect.gen(function* () { const development = yield* makeEnvironment( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 398f187ec..b8c730726 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -52,6 +52,13 @@ export class DesktopEnvironment extends Context.Service< readonly browserArtifactsDir: string; readonly rootDir: string; readonly appRoot: string; + // Root of the tree containing apps/server/dist and node_modules for the + // backend. Equals appRoot everywhere except packaged Windows, where the + // server tree ships as the resources/server.asar sidecar (see + // scripts/build-desktop-artifact.ts) that the asar-aware + // ELECTRON_RUN_AS_NODE primary reads in place and the WSL backend + // extracts on demand (see DesktopWslServerTree). + readonly serverRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; readonly preloadPath: string; @@ -182,6 +189,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( }); const rootDir = path.resolve(input.dirname, "../../.."); const appRoot = input.isPackaged ? input.appPath : rootDir; + const serverRoot = + input.isPackaged && input.platform === "win32" + ? path.join(input.resourcesPath, "server.asar") + : appRoot; const branding = resolveDesktopAppBranding({ isDevelopment, appVersion: input.appVersion, @@ -231,7 +242,8 @@ const make = Effect.fn("desktop.environment.make")(function* ( browserArtifactsDir: path.join(stateDir, "browser-artifacts"), rootDir, appRoot, - backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), + serverRoot, + backendEntryPath: path.join(serverRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 309dbb21d..2bbde73ab 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -17,6 +17,7 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; const PersistedServerObservabilitySettingsDocument = Schema.Struct({ observability: Schema.Struct({ @@ -115,6 +116,7 @@ const withHarness = ( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), ), ), @@ -153,6 +155,47 @@ describe("DesktopBackendConfiguration", () => { ), ); + it.effect("resolvePrimary starts from server.asar without materializing the WSL tree", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + const resourcesPath = `${baseDir}/resources`; + + const config = yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + return yield* configuration.resolvePrimary; + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslEnvironment.layerTest()), + Layer.provideMerge( + Layer.succeed( + DesktopWslServerTree.DesktopWslServerTree, + DesktopWslServerTree.DesktopWslServerTree.of({ + ensure: Effect.die("Windows primary must not extract the WSL server tree"), + }), + ), + ), + Layer.provideMerge( + makeEnvironmentLayer(baseDir, { + appPath: `${resourcesPath}/app.asar`, + platform: "win32", + resourcesPath, + }), + ), + ), + ), + ); + + assert.equal(config.entryPath, `${resourcesPath}/server.asar/apps/server/dist/bin.mjs`); + assert.equal(config.env.ELECTRON_RUN_AS_NODE, "1"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl reuses the primary's bootstrap token", () => withHarness( Effect.gen(function* () { @@ -173,7 +216,7 @@ describe("DesktopBackendConfiguration", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); @@ -186,6 +229,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -234,7 +278,7 @@ describe("DesktopBackendConfiguration", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-desktop-backend-config-test-", }); - const entryPath = path.join(baseDir, "app.asar.unpacked/apps/server/dist/bin.mjs"); + const entryPath = path.join(baseDir, "apps/server/dist/bin.mjs"); yield* fileSystem.makeDirectory(path.dirname(entryPath), { recursive: true }); yield* fileSystem.writeFileString(entryPath, ""); @@ -250,6 +294,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -386,6 +431,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge(makeEnvironmentLayer(baseDir)), Layer.provideMerge(failingFileSystemLayer), @@ -427,6 +473,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -486,6 +533,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -536,6 +584,7 @@ describe("DesktopBackendConfiguration", () => { wslOnly: true, }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -573,6 +622,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Removed-Distro", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -606,6 +656,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -640,6 +691,49 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + distros: [{ name: "Ubuntu", isDefault: true, version: 2 }], + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("resolveWsl surfaces sidecar extraction failures through typed preflight", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: "Ubuntu" }); + const failure = Option.getOrThrow(config.preflightFailure); + + assert.isFalse(failure.fatal); + assert.equal(failure.retryLimit, 12); + assert.include(failure.reason, "could not be extracted"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge( + DesktopWslServerTree.layerTest({ + result: { + ok: false, + reason: "WSL server files could not be extracted", + fatal: false, + }, + }), + ), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -672,6 +766,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge( DesktopWslEnvironment.layerTest({ isAvailable: true, @@ -708,6 +803,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Ubuntu", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: true })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -748,6 +844,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -793,6 +890,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest()), Layer.provideMerge( makeEnvironmentLayer(baseDir, { @@ -843,6 +941,7 @@ describe("DesktopBackendConfiguration", () => { wslDistro: "Ubuntu", }), ), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layerTest({ isAvailable: false })), Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), ), @@ -864,6 +963,7 @@ describe("DesktopBackendConfiguration", () => { DesktopBackendConfiguration.layer.pipe( Layer.provideMerge(serverExposureLayer), Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), Layer.provideMerge(DesktopWslEnvironment.layer), // isAvailable on win32 only touches the filesystem, never the spawner, // so a die-stub is enough to satisfy the layer's deps. diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfb9d6900..bcce731a5 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -19,6 +19,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "../wsl/DesktopWslServerTree.ts"; export class DesktopBackendObservabilitySettingsReadError extends Schema.TaggedErrorClass()( "DesktopBackendObservabilitySettingsReadError", @@ -424,10 +425,12 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl never, | DesktopEnvironment.DesktopEnvironment | DesktopWslEnvironment.DesktopWslEnvironment + | DesktopWslServerTree.DesktopWslServerTree | FileSystem.FileSystem > { const environment = yield* DesktopEnvironment.DesktopEnvironment; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; // 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 @@ -464,31 +467,31 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl ...buildObservabilityFragment(input.observabilitySettings), }; - // In packaged builds environment.appRoot is .../resources/app.asar — an - // archive FILE. The Windows primary reads its entry through - // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain - // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks - // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts) - // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is - // already a real directory, so this is a no-op. - const wslAppRoot = environment.isPackaged - ? environment.path.join(environment.resourcesPath, "app.asar.unpacked") - : environment.appRoot; + // 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 = 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, - }); + 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); // Every operation after preflight uses the same concrete distro. In // default-tracking mode this closes the race where the system default @@ -610,6 +613,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; const wslEnvironment = yield* DesktopWslEnvironment.DesktopWslEnvironment; + const wslServerTree = yield* DesktopWslServerTree.DesktopWslServerTree; const settings = yield* DesktopAppSettings.DesktopAppSettings; const crypto = yield* Crypto.Crypto; // SynchronizedRef (not a plain Ref) so the read-generate-write is atomic. @@ -665,6 +669,7 @@ export const make = Effect.gen(function* () { }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); }); @@ -727,6 +732,7 @@ export const make = Effect.gen(function* () { return yield* resolveWslStartConfig({ ...shared, ...input }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopWslEnvironment.DesktopWslEnvironment, wslEnvironment), + Effect.provideService(DesktopWslServerTree.DesktopWslServerTree, wslServerTree), Effect.provideService(FileSystem.FileSystem, fileSystem), ); }).pipe( diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 0616184ec..14caeed8a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -62,6 +62,7 @@ import * as PreviewManager from "./preview/Manager.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; +import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -165,6 +166,7 @@ const desktopBackendLayer = DesktopBackendPool.layer.pipe( Layer.provideMerge(DesktopAppIdentity.layer), Layer.provideMerge(DesktopBackendConfiguration.layer), Layer.provideMerge(DesktopWslEnvironment.layer), + Layer.provideMerge(DesktopWslServerTree.layer), Layer.provideMerge(DesktopTelemetryPublisher.layer), Layer.provideMerge(desktopWindowLayer), ); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.test.ts b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts new file mode 100644 index 000000000..8c1a5b020 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslServerTree.test.ts @@ -0,0 +1,323 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; + +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as DesktopWslServerTree from "./DesktopWslServerTree.ts"; + +// The service reads packaged Windows roots through the (asar-aware, in +// Electron) fs, so a plain directory named server.asar exercises the full +// extraction path under plain Node. + +const environmentLayer = (input: { + readonly baseDir: string; + readonly resourcesPath: string; + readonly appVersion?: string; + readonly isPackaged?: boolean; +}) => + DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: input.baseDir, + platform: "win32", + processArch: "x64", + appVersion: input.appVersion ?? "1.2.3", + appPath: "/repo", + isPackaged: input.isPackaged ?? true, + resourcesPath: input.resourcesPath, + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: input.baseDir, + T3CODE_MODE: "desktop", + }), + ), + ), + ); + +const withTempDir = ( + run: (tempDir: string) => Effect.Effect, +): Effect.Effect< + A, + E | PlatformError.PlatformError, + FileSystem.FileSystem | Exclude +> => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-wsl-server-tree-test-", + }); + return yield* run(tempDir); + }).pipe(Effect.scoped); + +const ensureWith = (input: { + readonly baseDir: string; + readonly resourcesPath: string; + readonly appVersion?: string; + readonly isPackaged?: boolean; +}) => + Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + return yield* tree.ensure; + }).pipe( + Effect.provide(DesktopWslServerTree.layer.pipe(Layer.provideMerge(environmentLayer(input)))), + ); + +describe("DesktopWslServerTree", () => { + it.effect("bounds entry work across an eight-way nested tree", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const visited = yield* Ref.make(0); + + yield* DesktopWslServerTree.forEachBoundedTree([{ depth: 0, id: "root" }], (node) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const current = yield* Ref.updateAndGet(active, (count) => count + 1); + yield* Ref.update(maxActive, (maximum) => Math.max(maximum, current)); + yield* Ref.update(visited, (count) => count + 1); + }), + () => + Effect.gen(function* () { + // Give every task in the current batch a chance to overlap. + yield* Effect.yieldNow; + if (node.depth === 4) return []; + return Array.from({ length: 8 }, (_, index) => ({ + depth: node.depth + 1, + id: `${node.id}.${String(index)}`, + })); + }), + () => Ref.update(active, (count) => count - 1), + ), + ); + + assert.equal(yield* Ref.get(active), 0); + assert.equal(yield* Ref.get(maxActive), 8); + assert.equal(yield* Ref.get(visited), 4_681); + }), + ); + + it.effect("returns the server root unchanged when it is a plain directory (dev)", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: tempDir, + isPackaged: false, + }); + assert.isTrue(result.ok); + assert.isFalse(result.ok && result.root.endsWith(".asar")); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("extracts an archive root into a version-keyed state directory", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "server-entry", + ); + yield* fileSystem.makeDirectory(path.join(serverRoot, "node_modules/effect"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "node_modules/effect/package.json"), + "{}", + ); + + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + + assert.isTrue(result.ok); + const root = result.ok ? result.root : ""; + assert.include(root, path.join("wsl-server-tree", "1.2.3")); + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "server-entry"); + const dep = yield* fileSystem.exists(path.join(root, "node_modules/effect/package.json")); + assert.isTrue(dep); + const marker = yield* fileSystem.readFileString( + path.join(root, "t3code-wsl-server-tree.json"), + ); + assert.include(marker, '"version":"1.2.3"'); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("serializes concurrent extraction callers and publishes one complete 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"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "server-entry", + ); + + const results = yield* Effect.gen(function* () { + const tree = yield* DesktopWslServerTree.DesktopWslServerTree; + return yield* Effect.all([tree.ensure, tree.ensure], { concurrency: "unbounded" }); + }).pipe( + Effect.provide( + DesktopWslServerTree.layer.pipe( + Layer.provideMerge(environmentLayer({ baseDir: tempDir, resourcesPath })), + ), + ), + ); + + assert.isTrue(results.every((result) => result.ok)); + const roots = results.flatMap((result) => (result.ok ? [result.root] : [])); + assert.lengthOf(new Set(roots), 1); + assert.equal( + yield* fileSystem.readFileString(path.join(roots[0] ?? "", "apps/server/dist/bin.mjs")), + "server-entry", + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reuses a completed extraction instead of copying again", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "v1"); + + const first = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(first.ok); + + // Mutate the source; a reused tree must keep the first copy. + yield* fileSystem.writeFileString( + path.join(serverRoot, "apps/server/dist/bin.mjs"), + "v2-should-not-appear", + ); + const second = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(second.ok); + const root = second.ok ? second.root : ""; + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "v1"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("sweeps stale version directories and leftover partials after extraction", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "x"); + + // T3CODE_HOME is set to tempDir, so the desktop state dir resolves to + // /userdata (no .t3 segment). + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.0.0"), { recursive: true }); + yield* fileSystem.makeDirectory(path.join(treeRoot, "1.2.3.partial"), { recursive: true }); + + const result = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isTrue(result.ok); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.0.0"))); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3.partial"))); + assert.isTrue(yield* fileSystem.exists(path.join(treeRoot, "1.2.3"))); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("re-extracts when the app version changes", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverRoot = path.join(tempDir, "resources", "server.asar"); + yield* fileSystem.makeDirectory(path.join(serverRoot, "apps/server/dist"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "old"); + + const first = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + appVersion: "1.2.3", + }); + assert.isTrue(first.ok); + + yield* fileSystem.writeFileString(path.join(serverRoot, "apps/server/dist/bin.mjs"), "new"); + const second = yield* ensureWith({ + baseDir: tempDir, + resourcesPath: path.join(tempDir, "resources"), + appVersion: "1.2.4", + }); + assert.isTrue(second.ok); + const root = second.ok ? second.root : ""; + assert.include(root, "1.2.4"); + const entry = yield* fileSystem.readFileString(path.join(root, "apps/server/dist/bin.mjs")); + assert.equal(entry, "new"); + // The previous version's tree is gone. + const treeRoot = path.dirname(root); + assert.isFalse(yield* fileSystem.exists(path.join(treeRoot, "1.2.3"))); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a retryable failure when the archive cannot be read", () => + withTempDir((tempDir) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* ensureWith({ + baseDir: tempDir, + // resources dir exists but server.asar does not + resourcesPath: path.join(tempDir, "resources"), + }); + assert.isFalse(result.ok); + if (!result.ok) { + assert.include(result.reason, "could not be extracted"); + assert.isFalse(result.fatal); + } + const treeRoot = path.join(tempDir, "userdata", "wsl-server-tree"); + const leftovers = yield* fileSystem + .readDirectory(treeRoot) + .pipe(Effect.orElseSucceed(() => [])); + assert.deepStrictEqual(leftovers, []); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/desktop/src/wsl/DesktopWslServerTree.ts b/apps/desktop/src/wsl/DesktopWslServerTree.ts new file mode 100644 index 000000000..0b87f7bf1 --- /dev/null +++ b/apps/desktop/src/wsl/DesktopWslServerTree.ts @@ -0,0 +1,226 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +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. +// +// Reading through Electron's patched fs also transparently returns the +// contents of files that electron-builder/asar left in the server.asar.unpacked +// sibling (native binaries), so a single walk of the archive yields the +// complete tree. + +export type WslServerTreeResult = + | { readonly ok: true; readonly root: string } + | { readonly ok: false; readonly reason: string; readonly fatal: boolean }; + +const MARKER_FILE_NAME = "t3code-wsl-server-tree.json"; +const COPY_CONCURRENCY = 8; + +const Marker = Schema.Struct({ version: Schema.String }); +const decodeMarker = Schema.decodeUnknownEffect(Schema.fromJsonString(Marker)); +const encodeMarker = Schema.encodeEffect(Schema.fromJsonString(Marker)); + +export class DesktopWslServerTreeExtractError extends Schema.TaggedErrorClass()( + "DesktopWslServerTreeExtractError", + { + targetDir: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to extract the WSL server tree to ${this.targetDir}.`; + } +} + +export class DesktopWslServerTree extends Context.Service< + DesktopWslServerTree, + { + // Resolves the directory the WSL backend should treat as the app root + // (the directory containing apps/server/dist and node_modules). In dev + // the checkout already is that directory; packaged Windows builds extract + // server.asar on first use. + readonly ensure: Effect.Effect; + } +>()("@t3tools/desktop/wsl/DesktopWslServerTree") {} + +// Child scheduling stays here instead of inside `visit`, so nested directories +// cannot create independent concurrency pools. The LIFO work list also keeps +// traversal memory proportional to the remaining frontier rather than the +// number of active fibers. +export const forEachBoundedTree = ( + roots: ReadonlyArray, + visit: (node: Node) => Effect.Effect, E, R>, +): Effect.Effect => + Effect.gen(function* () { + const pending = [...roots]; + while (pending.length > 0) { + const batch = pending.splice(-COPY_CONCURRENCY); + const children = yield* Effect.forEach(batch, visit, { + concurrency: COPY_CONCURRENCY, + }); + for (const entries of children) { + pending.push(...entries); + } + } + }); + +interface CopyTreeEntry { + readonly sourcePath: string; + readonly targetPath: string; +} + +// Copy using only operations supported by Electron's asar-patched fs. Symlinks +// are not expected because the sidecar is installed with a hoisted, physical +// layout; anything that is neither a file nor a directory is skipped. +const copyTree = ( + fs: FileSystem.FileSystem, + join: (first: string, ...rest: string[]) => string, + from: string, + to: string, +): Effect.Effect => + forEachBoundedTree( + [{ sourcePath: from, targetPath: to }], + ({ sourcePath, targetPath }) => + Effect.gen(function* () { + const info = yield* fs.stat(sourcePath); + if (info.type === "Directory") { + yield* fs.makeDirectory(targetPath, { recursive: true }); + const entries = yield* fs.readDirectory(sourcePath); + return entries.map((entry) => ({ + sourcePath: join(sourcePath, entry), + targetPath: join(targetPath, entry), + })); + } + if (info.type === "File") { + // Read and write stay in the same bounded task, so at most eight file + // buffers can be retained while their writes complete. + const bytes = yield* fs.readFile(sourcePath); + yield* fs.writeFile(targetPath, bytes); + } + return []; + }), + ); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const join = environment.path.join; + + const serverRoot = environment.serverRoot; + const needsExtraction = environment.isPackaged && environment.platform === "win32"; + const treeRoot = join(environment.stateDir, "wsl-server-tree"); + const version = environment.appVersion; + const versionDir = join(treeRoot, version); + + // Remove sibling trees left behind by previous app versions (and aborted + // extractions). Best-effort: a locked file must not block the backend. + const sweepStale = Effect.gen(function* () { + const entries = yield* fs.readDirectory(treeRoot).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + entries.filter((entry) => entry !== version), + (entry) => fs.remove(join(treeRoot, entry), { recursive: true }).pipe(Effect.ignore), + { discard: true }, + ); + }); + + const markerMatches = Effect.gen(function* () { + const raw = yield* fs.readFileString(join(versionDir, MARKER_FILE_NAME)); + const marker = yield* decodeMarker(raw); + return marker.version === version; + }).pipe(Effect.orElseSucceed(() => false)); + + const extract = Effect.gen(function* () { + yield* Effect.log(`[wsl-server-tree] Extracting ${serverRoot} to ${versionDir}...`); + yield* fs.makeDirectory(treeRoot, { recursive: true }); + // Keep the temporary tree beside the target so rename is atomic. Cleanup + // is owned explicitly because a scoped temp-directory finalizer treats the + // successful rename (and therefore missing original path) as an error. + const partialDir = yield* fs.makeTempDirectory({ + directory: treeRoot, + prefix: `.${version}.extract-`, + }); + yield* Effect.gen(function* () { + yield* copyTree(fs, join, serverRoot, partialDir); + const markerJson = yield* encodeMarker({ version }); + yield* fs.writeFileString(join(partialDir, MARKER_FILE_NAME), `${markerJson}\n`); + // The marker is written before the rename, so a directory named after + // the version is complete by construction. + yield* fs.remove(versionDir, { recursive: true }).pipe(Effect.ignore); + yield* fs.rename(partialDir, versionDir); + }).pipe( + Effect.ensuring(fs.remove(partialDir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); + yield* Effect.log(`[wsl-server-tree] Extraction complete at ${versionDir}.`); + }).pipe( + Effect.mapError( + (cause) => new DesktopWslServerTreeExtractError({ targetDir: versionDir, cause }), + ), + ); + + // Serialize concurrent ensure calls (backend restarts can overlap): the + // first caller extracts, later callers see the marker and reuse the tree. + const gate = yield* Semaphore.make(1); + + const ensure: Effect.Effect = gate + .withPermits(1)( + Effect.gen(function* () { + if (!needsExtraction) { + return { ok: true, root: serverRoot } as const; + } + if (yield* markerMatches) { + yield* sweepStale; + return { ok: true, root: versionDir } as const; + } + const result = yield* extract.pipe( + Effect.map(() => ({ ok: true, root: versionDir }) as const), + // Retryable: transient antivirus locks and slow disks are the common + // causes, and the backend manager already bounds preflight retries. + Effect.catch((error) => + Effect.succeed({ + ok: false, + reason: `WSL server files could not be extracted to ${versionDir}: ${ + error.cause instanceof Error ? error.cause.message : String(error.cause) + }`, + fatal: false, + } as const), + ), + ); + if (result.ok) { + yield* sweepStale; + } + return result; + }), + ) + .pipe(Effect.withSpan("desktop.wslServerTree.ensure")); + + return DesktopWslServerTree.of({ ensure }); +}); + +export const layer = Layer.effect(DesktopWslServerTree, make); + +export interface DesktopWslServerTreeTestStub { + readonly result?: WslServerTreeResult; +} + +export const layerTest = (stub: DesktopWslServerTreeTestStub = {}) => + Layer.effect( + DesktopWslServerTree, + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return DesktopWslServerTree.of({ + ensure: Effect.succeed(stub.result ?? { ok: true, root: environment.appRoot }), + }); + }), + ); diff --git a/apps/server/package.json b/apps/server/package.json index 8e7b5b385..3d93c9fe5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "@opencode-ai/sdk": "^1.3.15", "@pierre/diffs": "catalog:", "effect": "catalog:", + "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", "yaml": "catalog:" }, diff --git a/docs/operations/release.md b/docs/operations/release.md index 3a4543e1c..a2acc6d7e 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -297,6 +297,37 @@ publication with generated notes and no token. - `electron-updater` reads `latest-mac.yml` on stable and `nightly-mac.yml` on nightly, for both Intel and Apple Silicon. - The workflow merges the per-arch mac manifests into one channel-specific mac manifest before publishing the GitHub Release. +### Windows payload topology and update validation + +Windows packages the bundled server and only its runtime-external/native +dependency closure in `resources/server.asar`. Native modules and helper +executables declared as unpacked by that archive must be present at the matching +paths below `resources/server.asar.unpacked`. The Windows-native backend reads +the archive in place through Electron. WSL cannot read ASAR files, so enabling +the WSL backend extracts the server tree once into the desktop state directory +under `wsl-server-tree/` and reuses the completed version until the app +is updated. + +The artifact builder rejects a Windows package when any of these invariants +break: + +- `resources/server.asar` is absent or does not contain the server entry. +- Any file marked unpacked in the ASAR header is absent from + `resources/server.asar.unpacked`. +- On same-architecture Windows builds, the packaged primary cannot load the fff + native library from inside `server.asar` through its `.unpacked` sibling. +- The isolated, extracted sidecar cannot load the server entry with plain Node. +- The external Windows resource monitor is absent. +- The unpacked Windows application contains more than 80 files. + +Cross-architecture Windows builds retain every structural and extracted-sidecar +check, but skip executing the target Electron binary. A same-architecture build +for each release target must exercise the primary native-load probe. + +NSIS differential packaging remains enabled. A sidecar layout transition can +produce a larger one-time download; subsequent small releases retain their +blockmaps, with a 60 MB maximum for a representative sidecar-to-sidecar update. + ## 0) npm OIDC trusted publishing setup (CLI) The workflow invokes `node apps/server/scripts/cli.ts publish` after aligning package versions. That diff --git a/patches/@ff-labs__fff-node@0.9.4.patch b/patches/@ff-labs__fff-node@0.9.4.patch index 2d0c16133..74c132926 100644 --- a/patches/@ff-labs__fff-node@0.9.4.patch +++ b/patches/@ff-labs__fff-node@0.9.4.patch @@ -11,16 +11,18 @@ index ee181aef5007e4bf34a49479c089ca30f73a320b..327e2c55c83cc4c50d396a3109190ef1 import { fileURLToPath } from "node:url"; import { getLibFilename, getNpmPackageName } from "./platform.js"; /** -@@ -46,6 +46,14 @@ function getPackageDir() { +@@ -46,6 +46,16 @@ function getPackageDir() { // Fallback: assume we're one level deep in src/ return dirname(currentDir); } +function resolveUnpackedAsarPath(binaryPath) { -+ const asarSegment = `${sep}app.asar${sep}`; -+ if (!binaryPath.includes(asarSegment)) { ++ const pathSegments = binaryPath.split(sep); ++ const asarIndex = pathSegments.findLastIndex((segment) => segment.endsWith(".asar")); ++ if (asarIndex === -1) { + return binaryPath; + } -+ const unpackedPath = binaryPath.replace(asarSegment, `${sep}app.asar.unpacked${sep}`); ++ pathSegments[asarIndex] = `${pathSegments[asarIndex]}.unpacked`; ++ const unpackedPath = pathSegments.join(sep); + return existsSync(unpackedPath) ? unpackedPath : binaryPath; +} /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b54f73c70..8aeccb230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ patchedDependencies: '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 - '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 + '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 @@ -467,7 +467,7 @@ importers: version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 - version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) + version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -477,6 +477,9 @@ importers: effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + msgpackr-extract: + specifier: 3.0.4 + version: 3.0.4 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -926,6 +929,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@electron/asar': + specifier: ^3.4.1 + version: 3.4.1 '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -13122,7 +13128,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.9.4': optional: true - '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)': + '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)': dependencies: ffi-rs: 1.3.2 optionalDependencies: @@ -19652,7 +19658,6 @@ snapshots: '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 - optional: true msgpackr@2.0.4: optionalDependencies: @@ -19748,7 +19753,6 @@ snapshots: node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 - optional: true node-gyp-build@4.8.4: optional: true diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 49e5c60cc..a59fd23cd 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -2,15 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import { + BundleNotSelfContainedError, BuildCommandFailedError, createStageWorkspaceConfig, createStagePatchedDependencies, @@ -27,6 +28,7 @@ import { LinuxIconResizeError, MacPasskeySigningConfigurationResolutionError, MissingMacPasskeyProvisioningProfileError, + packWindowsServerAsar, renderMacPasskeyEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, @@ -46,9 +48,17 @@ import { resolvePackageManagerUserAgent, stageLinuxIconSize, STAGE_INSTALL_ARGS, - WINDOWS_ASAR_UNPACK, ancestorNodeModulesPaths, copyDirectoryPreservingSymlinks, + validateWindowsPackagedPayload, + WindowsPrimaryNativeProbeError, + WindowsPackagedPayloadValidationError, + WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT, + WINDOWS_SERVER_ASAR_IGNORE_GLOBS, + WINDOWS_SERVER_EXTRA_RESOURCES, + WINDOWS_SERVER_ASAR_RESOURCE, + WINDOWS_SERVER_ASAR_UNPACK_GLOB, + WINDOWS_SERVER_RESOURCE_SOURCE_DIR, } from "./build-desktop-artifact.ts"; import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -90,6 +100,54 @@ function iconResizeSpawnerLayer( ); } +const makeWindowsPayloadFixture = Effect.fn("test.makeWindowsPayloadFixture")(function* (input: { + readonly copyUnpackedNatives: boolean; + readonly serverEntrySource?: string; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-windows-payload-test-", + }); + const sourceDir = path.join(tempDir, "server-source"); + const serverEntryPath = path.join(sourceDir, "apps/server/dist/bin.mjs"); + const nativePath = path.join(sourceDir, "node_modules/native/addon.node"); + yield* fs.makeDirectory(path.dirname(serverEntryPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString(serverEntryPath, input.serverEntrySource ?? "console.log('server');\n"); + yield* fs.writeFileString(nativePath, "native-binary"); + + const generatedAsarPath = path.join(tempDir, WINDOWS_SERVER_ASAR_RESOURCE); + yield* packWindowsServerAsar({ sourceDir, asarPath: generatedAsarPath }); + + const stageDistDir = path.join(tempDir, "dist"); + const packagedAppDir = path.join(stageDistDir, "win-unpacked"); + const resourcesDir = path.join(packagedAppDir, "resources"); + yield* fs.makeDirectory(path.join(resourcesDir, "resource-monitor"), { recursive: true }); + yield* fs.copyFile(generatedAsarPath, path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE)); + if (input.copyUnpackedNatives) { + yield* fs.copy( + `${generatedAsarPath}.unpacked`, + path.join(resourcesDir, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`), + ); + } + yield* fs.writeFileString( + path.join(resourcesDir, "resource-monitor/t3-resource-monitor.exe"), + "monitor", + ); + const appExecutableName = "t3code.exe"; + yield* fs.writeFileString(path.join(packagedAppDir, appExecutableName), "electron"); + yield* fs.writeFileString(path.join(packagedAppDir, "chrome_crashpad_handler.exe"), "crashpad"); + + return { + stageDistDir, + packagedAppDir, + sourceDir, + generatedAsarPath, + appExecutableName, + } as const; +}); + it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { it("resolves the dedicated nightly updater channel from nightly versions", () => { assert.equal(resolveDesktopUpdateChannel("0.0.17-nightly.20260413.42"), "nightly"); @@ -255,22 +313,40 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { libc: ["glibc"], }, }); - // Windows artifacts also bundle the same-architecture WSL (Linux, glibc) backend, so the - // staged install must fetch its native optional deps (e.g. ffi-rs) too. + // The Windows app stage only serves the desktop main process; the server + // sidecar stage is the one that needs Linux natives (below). assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "x64" }), { supportedArchitectures: { - os: ["win32", "linux"], + os: ["win32"], cpu: ["x64"], - libc: ["glibc"], }, }); - assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { - supportedArchitectures: { - os: ["win32", "linux"], - cpu: ["arm64"], - libc: ["glibc"], + // The server sidecar stage bundles the same-architecture WSL (Linux, + // glibc) backend, so its install must fetch Linux native optional deps + // (e.g. ffi-rs) too — and must be hoisted so the tree survives asar + // packing and runtime extraction without symlinks. + assert.deepStrictEqual( + createStageWorkspaceConfig({ platform: "win", arch: "x64", linuxServerBackend: true }), + { + supportedArchitectures: { + os: ["win32", "linux"], + cpu: ["x64"], + libc: ["glibc"], + }, + nodeLinker: "hoisted", }, - }); + ); + assert.deepStrictEqual( + createStageWorkspaceConfig({ platform: "win", arch: "arm64", linuxServerBackend: true }), + { + supportedArchitectures: { + os: ["win32", "linux"], + cpu: ["arm64"], + libc: ["glibc"], + }, + nodeLinker: "hoisted", + }, + ); assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { supportedArchitectures: { os: ["darwin"], @@ -341,6 +417,16 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.deepStrictEqual(DESKTOP_ELECTRON_LANGUAGES, ["en-US"]); assert.deepStrictEqual(DESKTOP_FILE_EXCLUSIONS, [ "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + "!apps/desktop/prod-resources/windows-server", + "!apps/desktop/prod-resources/windows-server/**/*", + ]); + assert.equal(WINDOWS_SERVER_RESOURCE_SOURCE_DIR, "apps/desktop/prod-resources/windows-server"); + assert.deepStrictEqual(WINDOWS_SERVER_EXTRA_RESOURCES, [ + { + from: "apps/desktop/prod-resources/windows-server", + to: ".", + filter: ["server.asar", "server.asar.unpacked/**/*"], + }, ]); }); @@ -374,9 +460,33 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { undefined, ); + // All platforms keep app.asar fully packed; Windows ships the server + // tree as the hand-packed server.asar sidecar in extraResources instead + // of unpacking thousands of loose files at install time. assert.notProperty(mac, "asarUnpack"); assert.notProperty(linux, "asarUnpack"); - assert.deepStrictEqual(win.asarUnpack, WINDOWS_ASAR_UNPACK); + assert.notProperty(win, "asarUnpack"); + assert.deepStrictEqual(win.extraResources, [ + { + from: "apps/desktop/prod-resources/resource-monitor", + to: "resource-monitor", + }, + ...WINDOWS_SERVER_EXTRA_RESOURCES, + ]); + assert.deepStrictEqual(win.nsis, { differentialPackage: true }); + // Native binaries and helper executables cannot load from inside an + // asar; everything else stays packed. The Claude SDK platform packages + // and .bin shims never ship. + assert.equal( + WINDOWS_SERVER_ASAR_UNPACK_GLOB, + "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}", + ); + assert.deepStrictEqual(WINDOWS_SERVER_ASAR_IGNORE_GLOBS, [ + "**/node_modules/@anthropic-ai/claude-agent-sdk-*", + "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", + "**/node_modules/.bin", + "**/node_modules/.bin/**", + ]); assert.equal((mac.mac as Record).identity, "-"); assert.equal((mac.mac as Record).hardenedRuntime, false); // Linux must register the Pylon renderer scheme so the generated .desktop @@ -418,6 +528,275 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }), ); + it.effect("validates every ASAR-unpacked native in the packaged Windows payload", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const result = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }); + + const secondAsarPath = path.join(path.dirname(fixture.generatedAsarPath), "second.asar"); + yield* packWindowsServerAsar({ + sourceDir: fixture.sourceDir, + asarPath: secondAsarPath, + }); + const [firstAsar, secondAsar] = yield* Effect.all([ + fs.readFile(fixture.generatedAsarPath), + fs.readFile(secondAsarPath), + ]); + + assert.equal(result.packagedAppDir, fixture.packagedAppDir); + assert.deepStrictEqual(result.unpackedFiles, ["node_modules/native/addon.node"]); + assert.isBelow(result.fileCount, WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT); + assert.deepStrictEqual(secondAsar, firstAsar); + }), + ), + ); + + it.effect("probes fff through the packaged Windows primary instead of helper executables", () => { + const commands: Array<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly options: { + readonly cwd?: string; + readonly env?: Readonly>; + }; + }> = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command as unknown as (typeof commands)[number]); + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }); + + const primaryProbe = commands.find( + (command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1", + ); + if (primaryProbe === undefined) return assert.fail("Windows primary probe was not spawned"); + + assert.equal( + primaryProbe.command, + path.join(fixture.packagedAppDir, fixture.appExecutableName), + ); + assert.deepStrictEqual(primaryProbe.args.slice(0, 3), [ + "--no-global-search-paths", + "--input-type=module", + "--eval", + ]); + assert.include(primaryProbe.args[3], "FileFinder.create"); + assert.equal( + primaryProbe.args[4], + path.join( + fixture.packagedAppDir, + "resources/server.asar/node_modules/@ff-labs/fff-node/dist/src/index.js", + ), + ); + assert.equal(primaryProbe.options.cwd, fixture.packagedAppDir); + assert.equal(primaryProbe.options.env?.NODE_PATH, ""); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + spawnerLayer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ); + }); + + it.effect("skips the primary native probe for cross-architecture Windows payloads", () => { + const commands: Array<{ + readonly command: string; + readonly options: { + readonly env?: Readonly>; + }; + }> = []; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + commands.push(command as unknown as (typeof commands)[number]); + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "arm64", + }); + + assert.isFalse( + commands.some((command) => command.options.env?.ELECTRON_RUN_AS_NODE === "1"), + ); + assert.isTrue( + commands.some( + (command) => + command.command === process.execPath && command.options.env?.NODE_PATH === "", + ), + ); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + spawnerLayer, + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ); + }); + + it.effect("rejects a cross-architecture Windows payload without its primary executable", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const executablePath = path.join(fixture.packagedAppDir, fixture.appExecutableName); + yield* fs.remove(executablePath); + + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "arm64", + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPrimaryNativeProbeError); + assert.equal(error.executablePath, executablePath); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(HostProcessPlatform, "win32"), + Layer.succeed(HostProcessArchitecture, "x64"), + ), + ), + ), + ); + + it.effect("rejects a packaged sidecar whose ASAR-unpacked native is missing", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: false }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "unpacked-native-missing"); + assert.deepStrictEqual(error.missingFiles, [ + "server.asar.unpacked/node_modules/native/addon.node", + ]); + }), + ), + ); + + it.effect("rejects directories in place of packaged executable files", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const nativePath = path.join( + fixture.packagedAppDir, + "resources/server.asar.unpacked/node_modules/native/addon.node", + ); + yield* fs.remove(nativePath); + yield* fs.makeDirectory(nativePath); + + const nativeError = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + assert.instanceOf(nativeError, WindowsPackagedPayloadValidationError); + assert.equal(nativeError.reason, "unpacked-native-missing"); + assert.deepStrictEqual(nativeError.missingFiles, [ + "server.asar.unpacked/node_modules/native/addon.node", + ]); + + yield* fs.remove(nativePath, { recursive: true }); + yield* fs.writeFileString(nativePath, "native-binary"); + const resourceMonitorPath = path.join( + fixture.packagedAppDir, + "resources/resource-monitor/t3-resource-monitor.exe", + ); + yield* fs.remove(resourceMonitorPath); + yield* fs.makeDirectory(resourceMonitorPath); + + const resourceMonitorError = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + assert.instanceOf(resourceMonitorError, WindowsPackagedPayloadValidationError); + assert.equal(resourceMonitorError.reason, "resource-monitor-missing"); + assert.deepStrictEqual(resourceMonitorError.missingFiles, [ + "resource-monitor/t3-resource-monitor.exe", + ]); + }), + ), + ); + + it.effect("rejects a Windows payload that regresses above the file-count budget", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ copyUnpackedNatives: true }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + fileLimit: 2, + }).pipe(Effect.flip); + + assert.instanceOf(error, WindowsPackagedPayloadValidationError); + assert.equal(error.reason, "file-limit-exceeded"); + assert.isAbove(error.fileCount ?? 0, 2); + }), + ), + ); + + it.effect("rejects a sidecar whose extracted server bundle cannot resolve", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeWindowsPayloadFixture({ + copyUnpackedNatives: true, + serverEntrySource: 'import "t3code-deliberately-missing-package";\n', + }); + const error = yield* validateWindowsPackagedPayload({ + stageDistDir: fixture.stageDistDir, + appExecutableName: fixture.appExecutableName, + targetArch: "x64", + }).pipe(Effect.flip); + + assert.instanceOf(error, BundleNotSelfContainedError); + assert.include(error.output, "t3code-deliberately-missing-package"); + }), + ), + ); + it.effect("preserves both Linux icon resize failures with structural context", () => { const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; @@ -907,7 +1286,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); // The self-containment check runs the packaged tree in a scratch directory. Its -// own node_modules holds the unpacked externals and must be ignored, but any +// own node_modules holds the sidecar externals and must be ignored, but any // node_modules *above* it would let Node's parent walk satisfy an import that is // missing from the package, so the probe refuses to run in that case. it("lists ancestor node_modules, nearest first, excluding the start directory", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index db9715c4b..ae933478c 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -4,8 +4,16 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeModule from "node:module"; +import { + createPackageWithOptions, + extractAll, + getRawHeader, + statFile, + type DirectoryRecord, +} from "@electron/asar"; + import { fromYaml } from "@t3tools/shared/schemaYaml"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { clerkFrontendApiHostnameFromPublishableKey } from "@t3tools/shared/relayAuth"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import rootPackageJson from "../package.json" with { type: "json" }; @@ -20,8 +28,8 @@ import { } from "./lib/brand-assets.ts"; import { getDefaultBuildArch } from "./lib/build-target-arch.ts"; import { - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, findInlinedExternalPackages, + selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { loadRepoEnv } from "./lib/public-config.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; @@ -71,6 +79,7 @@ const StageWorkspaceConfig = Schema.Struct({ allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), + nodeLinker: Schema.optional(Schema.Literals(["hoisted"])), }); type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; @@ -388,18 +397,36 @@ const desktopBuildInputArtifactNames = { /** * Imported by every server module, so it is inlined in any correctly bundled * build. Its absence means the bundle went back to externalizing its - * dependencies, which the unpack globs do not cover. + * dependencies, which the sidecar's selected runtime closure does not cover. */ const BUNDLE_SELF_CONTAINED_SENTINEL = "effect"; const BUNDLE_SELF_CHECK_TIMEOUT = Duration.seconds(120); +const WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT = Duration.seconds(30); + +const WINDOWS_PRIMARY_FFF_PROBE_SOURCE = ` +const { join } = await import("node:path"); +const { pathToFileURL } = await import("node:url"); +const { FileFinder } = await import(pathToFileURL(process.argv[1]).href); +const probeRoot = process.argv[2]; +const result = FileFinder.create({ + basePath: probeRoot, + frecencyDbPath: join(probeRoot, "frecency.mdb"), + historyDbPath: join(probeRoot, "history.mdb"), + disableWatch: true, + disableMmapCache: true, + disableContentIndexing: true, +}); +if (!result.ok) throw new Error(result.error); +result.value.destroy(); +`; export class ExternalizedBundleError extends Schema.TaggedErrorClass()( "ExternalizedBundleError", { sentinel: Schema.String, inlinedPackageCount: Schema.Number }, ) { override get message(): string { - return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the native externals; if its dependencies are external again they will not be unpacked, and the WSL backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; + return `The server bundle did not inline "${this.sentinel}" (${this.inlinedPackageCount} packages inlined). The bundle is meant to be self-contained apart from the runtime externals; if its dependencies are external again they will be absent from the sidecar, and the backend will fail with ERR_MODULE_NOT_FOUND. Check the deps.alwaysBundle wiring in apps/server/vite.config.ts.`; } } @@ -408,7 +435,7 @@ export class BundleNotSelfContainedError extends Schema.TaggedErrorClass()( + "WindowsServerSidecarPackError", + { + asarPath: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to pack the Windows server sidecar at ${this.asarPath}.`; + } +} + +export class WindowsPrimaryNativeProbeError extends Schema.TaggedErrorClass()( + "WindowsPrimaryNativeProbeError", + { + executablePath: Schema.String, + exitCode: Schema.Number, + output: Schema.String, + }, +) { + override get message(): string { + return `The packaged Windows primary could not load fff from server.asar (exit ${this.exitCode}). Output:\n${this.output}`; + } +} + +const WindowsPackagedPayloadValidationReason = Schema.Literals([ + "packaged-app-missing", + "sidecar-missing", + "sidecar-invalid", + "unpacked-native-missing", + "resource-monitor-missing", + "file-limit-exceeded", +]); + +export class WindowsPackagedPayloadValidationError extends Schema.TaggedErrorClass()( + "WindowsPackagedPayloadValidationError", + { + reason: WindowsPackagedPayloadValidationReason, + packagedAppDir: Schema.String, + missingFiles: Schema.optionalKey(Schema.Array(Schema.String)), + fileCount: Schema.optionalKey(Schema.Int), + fileLimit: Schema.optionalKey(Schema.Int), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + if (this.reason === "file-limit-exceeded") { + return `Windows packaged payload contains ${String(this.fileCount)} files; expected at most ${String(this.fileLimit)}.`; + } + if (this.reason === "unpacked-native-missing") { + return `Windows server sidecar is missing ${String(this.missingFiles?.length ?? 0)} unpacked native files.`; + } + if (this.reason === "resource-monitor-missing") { + return "Windows packaged payload is missing the resource monitor executable."; + } + if (this.reason === "sidecar-invalid") { + return "Windows packaged payload contains an invalid server.asar sidecar."; + } + if (this.reason === "sidecar-missing") { + return "Windows packaged payload is missing resources/server.asar."; + } + return `Windows packaged application directory was not found at ${this.packagedAppDir}.`; + } +} + export class WslNodePtyManifestReadError extends Schema.TaggedErrorClass()( "WslNodePtyManifestReadError", { @@ -689,21 +781,47 @@ export const DESKTOP_FILE_EXCLUSIONS = [ // so the SDK's optional platform packages (each a ~200MB bundled executable) // are dead weight. The trailing dash keeps the SDK's own JS package. "!**/node_modules/@anthropic-ai/claude-agent-sdk-*/**/*", + // Windows stages the server sidecar below prod-resources so electron-builder + // can copy it using project-relative extraResources matchers. Keep those + // staging inputs out of app.asar; they are emitted once at resources/. + "!apps/desktop/prod-resources/windows-server", + "!apps/desktop/prod-resources/windows-server/**/*", ] as const; -// The WSL backend launches the server with plain `wsl.exe -- node`, which cannot -// read inside an asar archive, so everything it loads must be on the real -// filesystem. This used to unpack `**\/node_modules\/**` wholesale, because the -// server bundle externalized its runtime deps and the Linux Node would fail with -// ERR_MODULE_NOT_FOUND ("Cannot find package 'effect'") before it even reached -// node-pty. -// -// The CLI bundle now inlines its JS dependencies, so the only things that still -// have to be loose are the server bundle itself and the packages the bundle -// leaves external — derived from the same list the bundler uses, so the two -// cannot drift apart. -export const WINDOWS_ASAR_UNPACK = [ - "apps/server/dist/**", - ...CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, +// Windows ships the server tree (bundle + node_modules) as a separate +// resources/server.asar sidecar instead of loose files: the NSIS installer +// then extracts a handful of large archives instead of thousands of small +// files, which dominates install (and update) time. The Windows primary runs +// the server from inside server.asar via the asar-aware ELECTRON_RUN_AS_NODE +// runtime; the WSL backend cannot read asar archives, so enabling WSL lazily +// extracts the sidecar to a version-keyed directory (see DesktopWslServerTree). +export const WINDOWS_SERVER_ASAR_RESOURCE = "server.asar"; +// dlopen/spawn need real files, so native modules, shared libraries, and +// helper executables live in the server.asar.unpacked sibling (the standard +// asar redirect convention). Everything else stays packed. +export const WINDOWS_SERVER_ASAR_UNPACK_GLOB = + "{**/*.node,**/*.dll,**/*.exe,**/*.so,**/*.so.*,**/*.dylib}"; +// Mirrors DESKTOP_FILE_EXCLUSIONS for the hand-packed sidecar: the Claude SDK +// platform packages are dead weight (see above), and node_modules/.bin shims +// are never spawned at runtime (and are symlinks on POSIX build hosts, which +// the asar extraction path deliberately does not support). +export const WINDOWS_SERVER_ASAR_IGNORE_GLOBS = [ + "**/node_modules/@anthropic-ai/claude-agent-sdk-*", + "**/node_modules/@anthropic-ai/claude-agent-sdk-*/**", + "**/node_modules/.bin", + "**/node_modules/.bin/**", +] as const; +export const WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT = 80; +export const WINDOWS_SERVER_RESOURCE_SOURCE_DIR = "apps/desktop/prod-resources/windows-server"; +export const WINDOWS_SERVER_EXTRA_RESOURCES = [ + { + // Copy the archive and its .unpacked sibling from one parent directory. + // Mapping the .unpacked directory as an independent FileSet silently + // omitted it from Windows packages even though electron-builder copied + // the adjacent archive. + from: WINDOWS_SERVER_RESOURCE_SOURCE_DIR, + to: ".", + filter: [WINDOWS_SERVER_ASAR_RESOURCE, `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/**/*`], + }, ] as const; export const DESKTOP_EXTRA_RESOURCES = [ { @@ -1047,14 +1165,20 @@ export function createStageWorkspaceConfig(input: { readonly allowBuilds?: Record; readonly patchedDependencies?: Record; readonly overrides?: Record; + // The Windows server sidecar stage runs both the Windows primary and the + // WSL Linux backend from one dependency tree, so it needs win32 + linux + // natives (e.g. @yuuang/ffi-rs-linux-x64-gnu) — and a hoisted (physical, + // symlink-free) node_modules: the tree gets packed into server.asar and + // later extracted for WSL, and neither step can rely on pnpm's + // symlink/junction layout surviving the trip. + readonly linuxServerBackend?: boolean; }): StageWorkspaceConfig { - const { platform, arch, allowBuilds, patchedDependencies, overrides } = input; + const { platform, arch, allowBuilds, patchedDependencies, overrides, linuxServerBackend } = input; const hostOs = platform === "mac" ? "darwin" : platform === "win" ? "win32" : "linux"; const hostCpu = arch === "universal" ? ["arm64", "x64"] : [arch]; - // Linux AppImages and Windows WSL backends both execute a Linux/glibc Node - // process that loads Linux-native optional deps at runtime (e.g. - // @yuuang/ffi-rs-linux-x64-gnu). Keep libc explicit so pnpm includes those - // optional packages in the staged production install. + // Linux AppImages execute a Linux/glibc Node process that loads + // Linux-native optional deps at runtime. Keep libc explicit so pnpm + // includes those optional packages in the staged production install. const supportedArchitectures = platform === "linux" ? { @@ -1062,7 +1186,7 @@ export function createStageWorkspaceConfig(input: { cpu: hostCpu, libc: ["glibc"], } - : platform === "win" + : linuxServerBackend ? { os: Array.from(new Set([hostOs, "linux"])), cpu: hostCpu, @@ -1080,6 +1204,7 @@ export function createStageWorkspaceConfig(input: { ? { patchedDependencies } : {}), ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), + ...(linuxServerBackend ? { nodeLinker: "hoisted" as const } : {}), }; } @@ -1447,36 +1572,27 @@ export const copyDirectoryPreservingSymlinks = Effect.fn("copyDirectoryPreservin ); const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSelfContained")( - function* (input: { readonly stageDistDir: string; readonly verbose: boolean }) { + function* (input: { readonly asarPath: string; readonly verbose: boolean }) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // electron-builder names this win-unpacked, win-arm64-unpacked, and so on. - const distEntries = yield* fs - .readDirectory(input.stageDistDir) - .pipe(Effect.orElseSucceed(() => [] as Array)); - let unpackedRoot: string | null = null; - for (const entry of distEntries) { - const candidate = path.join(input.stageDistDir, entry, "resources/app.asar.unpacked"); - if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - unpackedRoot = candidate; - break; - } - } - // Nothing to verify rather than silently passing: a packaging layout change - // should surface here instead of turning the check into a no-op. - if (unpackedRoot === null) { - return yield* new BundleNotSelfContainedError({ - exitCode: -1, - output: `No */resources/app.asar.unpacked directory under ${input.stageDistDir}; the bundle self-containment check found nothing to verify.`, - }); - } - const probeRoot = yield* fs.makeTempDirectoryScoped({ prefix: "pylon-bundle-selfcheck-", }); + const extractedApp = path.join(probeRoot, "extracted"); const probeApp = path.join(probeRoot, "app"); - yield* copyDirectoryPreservingSymlinks(unpackedRoot, probeApp); + yield* Effect.try({ + try: () => extractAll(input.asarPath, extractedApp), + catch: (cause) => + new BundleNotSelfContainedError({ + exitCode: -1, + output: `Could not extract ${input.asarPath} for the bundle self-containment check: ${String(cause)}`, + }), + }); + // Keep the existing symlink isolation guard even though the sidecar stage + // is hoisted and should be physical. A future package-manager layout change + // must not let the probe resolve through the build tree. + yield* copyDirectoryPreservingSymlinks(extractedApp, probeApp); // Guard the guard: if anything above the probe provides a node_modules, a // missing dependency would resolve there and the check would pass while the @@ -1502,8 +1618,8 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel // missing dependency shows up, without starting a server or touching disk // state. It does not cover lazily imported externals: node-pty is checked // by the WSL preflight probe at runtime, while ffi-rs, @ff-labs/fff-node - // and the bun adapters are only covered by the unpack globs and the - // inlined-native check below. + // and the bun adapters are covered by the shared runtime-external closure + // and emitted-bundle checks. yield* runCommand( ChildProcess.make( process.execPath, @@ -1522,7 +1638,10 @@ const verifyPackagedBundleIsSelfContained = Effect.fn("verifyPackagedBundleIsSel env: { ...process.env, NODE_PATH: "" }, }, ), - { label: "bundle self-containment check (node bin.mjs --version)", verbose: input.verbose }, + { + label: "server sidecar self-containment check (node bin.mjs --version)", + verbose: input.verbose, + }, ).pipe( // Printing a version should be immediate. A regression that blocks (on // stdin, a port, a lock) would otherwise hang release CI until the job @@ -1928,11 +2047,14 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( directories: { buildResources: "apps/desktop/resources", }, - // Only the Windows WSL backend needs files outside the asar (see - // WINDOWS_ASAR_UNPACK); macOS and Linux stay packed — smart unpack - // extracts native libraries, which fff-node finds in app.asar.unpacked. - ...(platform === "win" ? { asarUnpack: [...WINDOWS_ASAR_UNPACK] } : {}), - extraResources: DESKTOP_EXTRA_RESOURCES, + // All platforms keep app.asar fully packed; electron-builder's default + // smart unpack extracts native libraries, which loaders find in + // app.asar.unpacked. Windows additionally ships the server tree as the + // hand-packed server.asar sidecar (see WINDOWS_SERVER_ASAR_RESOURCE). + extraResources: [ + ...DESKTOP_EXTRA_RESOURCES, + ...(platform === "win" ? WINDOWS_SERVER_EXTRA_RESOURCES : []), + ], }; const updateChannel = resolveDesktopUpdateChannel(version); const publishConfig = yield* resolveGitHubPublishConfig(updateChannel); @@ -1997,6 +2119,10 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "win") { buildConfig.npmRebuild = false; + // Keep blockmap-based differential downloads enabled while changing the + // installed file topology. The optimization is in the payload shape, not + // in trading update bandwidth for install speed. + buildConfig.nsis = { differentialPackage: true }; const winConfig: Record = { target: [target], icon: "icon.ico", @@ -2105,6 +2231,381 @@ const stageWslNodePtyPrebuild = Effect.fn("stageWslNodePtyPrebuild")(function* ( ); }); +// Stage and pack the Windows server sidecar: the bundled server plus a hoisted +// install of only its runtime-external/native dependency closure for win32 and +// WSL Linux. The Windows primary runs from the archive through the asar-aware +// ELECTRON_RUN_AS_NODE runtime; enabling WSL extracts it to a real directory. +// Shipping one packed archive instead of thousands of loose files is what +// makes the NSIS install/update fast. +export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function* (input: { + readonly sourceDir: string; + readonly asarPath: string; +}) { + const fs = yield* FileSystem.FileSystem; + yield* Effect.tryPromise({ + try: () => + createPackageWithOptions(input.sourceDir, input.asarPath, { + dot: true, + unpack: WINDOWS_SERVER_ASAR_UNPACK_GLOB, + globOptions: { ignore: [...WINDOWS_SERVER_ASAR_IGNORE_GLOBS] }, + }), + catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), + }); + const unpackedDirPath = `${input.asarPath}.unpacked`; + if (!(yield* fs.exists(unpackedDirPath))) { + return yield* new WindowsServerSidecarPackError({ + asarPath: input.asarPath, + cause: new Error(`expected native binaries at ${unpackedDirPath}, but none were unpacked`), + }); + } +}); + +export const stageWindowsServerSidecar = Effect.fn("stageWindowsServerSidecar")(function* (input: { + readonly stageRoot: string; + readonly repoRoot: string; + readonly serverDistDir: string; + readonly arch: typeof BuildArch.Type; + readonly appVersion: string; + readonly runtimeExternalDependencies: Record; + readonly fffNodeVersion: string; + readonly allowBuilds: Record; + readonly patchedDependencies: Record; + readonly overrides: Record; + readonly wslPrebuildPath: string | undefined; + readonly asarPath: string; + readonly verbose: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const serverStageDir = path.join(input.stageRoot, "server"); + yield* fs.makeDirectory(path.join(serverStageDir, "apps/server"), { recursive: true }); + yield* fs.copy(input.serverDistDir, path.join(serverStageDir, "apps/server/dist")); + + const sidecarDependencies = { + ...input.runtimeExternalDependencies, + // The sidecar serves two processes: the Windows primary loads win32 + // natives, and the WSL backend loads the matching Linux natives (fff via + // ffi-rs) from the extracted copy of this same tree. + ...resolveFffNativeDependencies("win", input.arch, input.fffNodeVersion), + ...resolveFffNativeDependencies("linux", input.arch, input.fffNodeVersion), + }; + const sidecarPatchedDependencies = createStagePatchedDependencies( + input.patchedDependencies, + sidecarDependencies, + ); + const sidecarPackageJson = { + name: "t3code-server", + version: input.appVersion, + private: true, + packageManager: rootPackageJson.packageManager, + dependencies: sidecarDependencies, + }; + const sidecarPackageJsonString = yield* encodeJsonString(sidecarPackageJson); + yield* fs.writeFileString( + path.join(serverStageDir, "package.json"), + `${sidecarPackageJsonString}\n`, + ); + const sidecarWorkspaceConfig = createStageWorkspaceConfig({ + platform: "win", + arch: input.arch, + allowBuilds: input.allowBuilds, + patchedDependencies: sidecarPatchedDependencies, + overrides: input.overrides, + linuxServerBackend: true, + }); + const sidecarWorkspaceConfigString = yield* encodeStageWorkspaceConfig(sidecarWorkspaceConfig); + yield* fs.writeFileString( + path.join(serverStageDir, "pnpm-workspace.yaml"), + sidecarWorkspaceConfigString, + ); + if (Object.keys(sidecarPatchedDependencies).length > 0) { + yield* fs.copy(path.join(input.repoRoot, "patches"), path.join(serverStageDir, "patches")); + } + + yield* Effect.log("[desktop-artifact] Installing server sidecar runtime externals..."); + const installCommand = yield* resolveSpawnCommand("vp", [...STAGE_INSTALL_ARGS]); + yield* runCommand( + ChildProcess.make(installCommand.command, installCommand.args, { + cwd: serverStageDir, + shell: installCommand.shell, + }), + { label: "vp install --prod (server sidecar)", verbose: input.verbose }, + ); + + yield* stageWslNodePtyPrebuild({ + stageAppDir: serverStageDir, + arch: input.arch, + prebuildPath: input.wslPrebuildPath, + }); + + yield* Effect.log("[desktop-artifact] Packing server.asar..."); + yield* fs.makeDirectory(path.dirname(input.asarPath), { recursive: true }); + yield* packWindowsServerAsar({ sourceDir: serverStageDir, asarPath: input.asarPath }); + const packedStat = yield* fs.stat(input.asarPath); + yield* Effect.log( + `[desktop-artifact] Packed server.asar (${String(packedStat.size)} bytes) + unpacked natives.`, + ); +}); + +function collectUnpackedAsarFiles( + directory: DirectoryRecord, + parentPath = "", + output: string[] = [], +): readonly string[] { + for (const [name, entry] of Object.entries(directory.files)) { + const entryPath = parentPath.length === 0 ? name : `${parentPath}/${name}`; + if ("files" in entry) { + collectUnpackedAsarFiles(entry, entryPath, output); + } else if (entry.unpacked) { + output.push(entryPath); + } + } + return output; +} + +const countPayloadFiles = Effect.fn("desktopArtifact.countPayloadFiles")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pendingDirectories = [root]; + let count = 0; + + while (pendingDirectories.length > 0) { + const directory = pendingDirectories.pop(); + if (directory === undefined) break; + const entries = yield* fs.readDirectory(directory); + for (const entry of entries) { + const entryPath = path.join(directory, entry); + const stat = yield* fs.stat(entryPath); + if (stat.type === "Directory") { + pendingDirectories.push(entryPath); + } else if (stat.type === "File") { + count += 1; + } + } + } + + return count; +}); + +export const verifyWindowsPrimaryFffNativeLoad = Effect.fn( + "desktopArtifact.verifyWindowsPrimaryFffNativeLoad", +)(function* (input: { + readonly packagedAppDir: string; + readonly asarPath: string; + readonly appExecutableName: string; + readonly targetArch: typeof BuildArch.Type; + readonly verbose: boolean; +}) { + const hostPlatform = yield* HostProcessPlatform; + const hostArchitecture = yield* HostProcessArchitecture; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const executablePath = path.join(input.packagedAppDir, input.appExecutableName); + const executableStat = yield* fs.stat(executablePath).pipe(Effect.orElseSucceed(() => null)); + if (executableStat?.type !== "File") { + return yield* new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: -1, + output: "The unpacked application does not contain its expected primary executable.", + }); + } + if (hostPlatform !== "win32" || hostArchitecture !== input.targetArch) return; + + const probeRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3code-windows-primary-native-probe-", + }); + const fffEntryPath = path.join( + input.asarPath, + "node_modules/@ff-labs/fff-node/dist/src/index.js", + ); + const probeEnv = { ...process.env }; + delete probeEnv.ELECTRON_NO_ASAR; + delete probeEnv.NODE_OPTIONS; + + yield* runCommand( + ChildProcess.make( + executablePath, + [ + "--no-global-search-paths", + "--input-type=module", + "--eval", + WINDOWS_PRIMARY_FFF_PROBE_SOURCE, + fffEntryPath, + probeRoot, + ], + { + cwd: input.packagedAppDir, + stdout: "pipe", + stderr: "pipe", + env: { + ...probeEnv, + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: "", + }, + }, + ), + { + label: "Windows primary fff native-load probe", + verbose: input.verbose, + }, + ).pipe( + Effect.timeout(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT), + Effect.catchTags({ + TimeoutError: () => + Effect.fail( + new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: -1, + output: `The native-load probe did not finish within ${Duration.toSeconds(WINDOWS_PRIMARY_NATIVE_PROBE_TIMEOUT)}s.`, + }), + ), + BuildCommandFailedError: (error) => + Effect.fail( + new WindowsPrimaryNativeProbeError({ + executablePath, + exitCode: error.exitCode, + output: `${error.stderrTail ?? ""}${error.stdoutTail ?? ""}`.trim(), + }), + ), + }), + ); +}); + +export const validateWindowsPackagedPayload = Effect.fn( + "desktopArtifact.validateWindowsPackagedPayload", +)(function* (input: { + readonly stageDistDir: string; + readonly appExecutableName: string; + readonly targetArch: typeof BuildArch.Type; + readonly fileLimit?: number; + readonly verbose?: boolean; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fileLimit = input.fileLimit ?? WINDOWS_PACKAGED_PAYLOAD_FILE_LIMIT; + const isFile = (filePath: string) => + fs.stat(filePath).pipe( + Effect.map((stat) => stat.type === "File"), + Effect.orElseSucceed(() => false), + ); + const stageEntries = yield* fs.readDirectory(input.stageDistDir); + let packagedAppDir: string | undefined; + + for (const entry of stageEntries) { + if (!entry.endsWith("-unpacked")) continue; + const candidate = path.join(input.stageDistDir, entry); + const stat = yield* fs.stat(candidate).pipe(Effect.orElseSucceed(() => null)); + if (stat?.type === "Directory") { + packagedAppDir = candidate; + break; + } + } + + if (packagedAppDir === undefined) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "packaged-app-missing", + packagedAppDir: path.join(input.stageDistDir, "win-unpacked"), + }); + } + + const resourcesDir = path.join(packagedAppDir, "resources"); + const asarPath = path.join(resourcesDir, WINDOWS_SERVER_ASAR_RESOURCE); + if (!(yield* fs.exists(asarPath).pipe(Effect.orElseSucceed(() => false)))) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "sidecar-missing", + packagedAppDir, + missingFiles: [WINDOWS_SERVER_ASAR_RESOURCE], + }); + } + + const unpackedFiles = yield* Effect.try({ + try: () => { + // The entry lookup proves the archive contains the server executable, + // while the single header walk identifies every file ASAR redirects to + // the unpacked sibling at runtime. + // @electron/asar resolves entry names using the host path separator. + // POSIX separators work on Linux/macOS but fail on Windows even when the + // entry is present in the archive. + statFile(asarPath, path.join("apps", "server", "dist", "bin.mjs")); + return [...collectUnpackedAsarFiles(getRawHeader(asarPath).header)].sort(); + }, + catch: (cause) => + new WindowsPackagedPayloadValidationError({ + reason: "sidecar-invalid", + packagedAppDir, + cause, + }), + }); + if (unpackedFiles.length === 0) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "sidecar-invalid", + packagedAppDir, + cause: new Error("server.asar does not declare any unpacked native files"), + }); + } + + const missingFiles: string[] = []; + for (const unpackedFile of unpackedFiles) { + const unpackedPath = path.join( + resourcesDir, + `${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked`, + ...unpackedFile.split("/"), + ); + if (!(yield* isFile(unpackedPath))) { + missingFiles.push(`${WINDOWS_SERVER_ASAR_RESOURCE}.unpacked/${unpackedFile}`); + } + } + if (missingFiles.length > 0) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "unpacked-native-missing", + packagedAppDir, + missingFiles, + }); + } + + const resourceMonitorPath = path.join( + resourcesDir, + "resource-monitor", + resourceMonitorExecutableName("win"), + ); + if (!(yield* isFile(resourceMonitorPath))) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "resource-monitor-missing", + packagedAppDir, + missingFiles: ["resource-monitor/t3-resource-monitor.exe"], + }); + } + + const fileCount = yield* countPayloadFiles(packagedAppDir); + if (fileCount > fileLimit) { + return yield* new WindowsPackagedPayloadValidationError({ + reason: "file-limit-exceeded", + packagedAppDir, + fileCount, + fileLimit, + }); + } + + yield* verifyWindowsPrimaryFffNativeLoad({ + packagedAppDir, + asarPath, + appExecutableName: input.appExecutableName, + targetArch: input.targetArch, + verbose: input.verbose ?? false, + }); + + yield* verifyPackagedBundleIsSelfContained({ + asarPath, + verbose: input.verbose ?? false, + }); + + yield* Effect.log( + `[desktop-artifact] Validated Windows payload (${String(fileCount)} files, ${String(unpackedFiles.length)} sidecar natives).`, + ); + return { packagedAppDir, fileCount, unpackedFiles } as const; +}); + const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options: ResolvedBuildOptions, ) { @@ -2153,6 +2654,9 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( cause, }), }); + const resolvedServerRuntimeExternalDependencies = selectCliRuntimeExternalDependencies( + resolvedServerDependencies, + ); const resolvedDesktopRuntimeDependencies = yield* Effect.try({ try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog), catch: (cause) => @@ -2243,7 +2747,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // inlined. A regression to externalizing everything would also pass it, // since source-file regions still exist -- and that is the failure this // whole change exists to prevent, because those packages are not in the - // unpack globs and the WSL backend would die on ERR_MODULE_NOT_FOUND. + // selected sidecar closure and both backends would die on ERR_MODULE_NOT_FOUND. // `effect` is imported by every server module, so it is inlined in any // correctly bundled build. // The list-based check above only sees packages someone already thought to @@ -2282,12 +2786,18 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* validateBundledClientAssets(path.dirname(bundledClientEntry)); yield* fs.makeDirectory(path.join(stageAppDir, "apps/desktop"), { recursive: true }); - yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); + if (options.platform !== "win") { + yield* fs.makeDirectory(path.join(stageAppDir, "apps/server"), { recursive: true }); + } yield* Effect.log("[desktop-artifact] Staging release app..."); yield* fs.copy(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron")); yield* fs.copy(distDirs.desktopResources, stageResourcesDir); - yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + // On Windows the server tree ships in the server.asar sidecar instead of + // app.asar (see stageWindowsServerSidecar), so the app stage omits it. + if (options.platform !== "win") { + yield* fs.copy(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist")); + } yield* stageResourceMonitor({ repoRoot, stageResourcesDir, @@ -2308,7 +2818,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); // electron-builder is filtering out stageResourcesDir directory in the AppImage for production - yield* fs.copy(stageResourcesDir, path.join(stageAppDir, "apps/desktop/prod-resources")); + const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources"); + yield* fs.copy(stageResourcesDir, stageProdResourcesDir); const configuredMacPasskeySigning = options.platform === "mac" && options.signed @@ -2338,30 +2849,31 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); } - const stageDependencies = { - ...resolvedServerDependencies, - ...resolvedDesktopRuntimeDependencies, - ...resolveFffNativeDependencies( - options.platform, - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ), - // Windows artifacts also bundle the same-architecture WSL Linux backend, which loads the - // fff native binary through ffi-rs. The platform fff binary above is the - // host's (win32), so promote the matching Linux fff binaries too; without - // them file-finding in WSL fails to load its Linux native package. - ...(options.platform === "win" - ? resolveFffNativeDependencies( - "linux", - options.arch, - serverPackageJson.dependencies["@ff-labs/fff-node"], - ) - : {}), - }; + // Windows splits dependencies per process: app.asar carries only the + // desktop main-process runtime deps, while the server bundle's deps live in + // the server.asar sidecar (see stageWindowsServerSidecar). macOS and Linux + // keep the single merged tree — their primary resolves everything from + // app.asar and there is no second consumer. + const stageDependencies = + options.platform === "win" + ? { ...resolvedDesktopRuntimeDependencies } + : { + ...resolvedServerDependencies, + ...resolvedDesktopRuntimeDependencies, + ...resolveFffNativeDependencies( + options.platform, + options.arch, + serverPackageJson.dependencies["@ff-labs/fff-node"], + ), + }; const stagePatchedDependencies = createStagePatchedDependencies( workspacePatchedDependencies, stageDependencies, ); + const windowsServerAsarPath = + options.platform === "win" + ? path.join(stageAppDir, WINDOWS_SERVER_RESOURCE_SOURCE_DIR, WINDOWS_SERVER_ASAR_RESOURCE) + : undefined; const stagePackageJson: StagePackageJson = { // Electron reads the package name before our main process can override // app paths, so this must match the isolated production profile too. @@ -2425,13 +2937,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ); yield* stageClerkPasskeyNativeBinaries(stageAppDir, options.platform, options.arch); - // WSL is Windows-only, so only the Windows artifact carries the Linux backend - // binary; other platforms ignore the prebuild input. - if (options.platform === "win") { - yield* stageWslNodePtyPrebuild({ - stageAppDir, + // WSL is Windows-only, so only the Windows artifact carries the server + // sidecar (which embeds the Linux node-pty prebuild); other platforms + // ignore the prebuild input. + if (options.platform === "win" && windowsServerAsarPath) { + yield* stageWindowsServerSidecar({ + stageRoot, + repoRoot, + serverDistDir: distDirs.serverDist, arch: options.arch, - prebuildPath: options.wslPrebuild, + appVersion, + runtimeExternalDependencies: resolvedServerRuntimeExternalDependencies, + fffNodeVersion: serverPackageJson.dependencies["@ff-labs/fff-node"], + allowBuilds: workspaceAllowBuilds, + patchedDependencies: workspacePatchedDependencies, + overrides: resolvedOverrides, + wslPrebuildPath: options.wslPrebuild, + asarPath: windowsServerAsarPath, + verbose: options.verbose, }); } @@ -2520,9 +3043,15 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // resolver has no such ambiguity: it either finds every import or it does not. // // Only Windows unpacks anything; macOS and Linux keep the whole tree inside - // the asar, where this check has nothing to look at. + // the app asar. Windows validates and executes the separately packed server + // sidecar after electron-builder copies it into the final payload. if (options.platform === "win") { - yield* verifyPackagedBundleIsSelfContained({ stageDistDir, verbose: options.verbose }); + yield* validateWindowsPackagedPayload({ + stageDistDir, + appExecutableName: `${resolveDesktopProductName(appVersion)}.exe`, + targetArch: options.arch, + verbose: options.verbose, + }); } const stageEntries = yield* fs.readDirectory(stageDistDir); diff --git a/scripts/lib/cli-external-packages.test.ts b/scripts/lib/cli-external-packages.test.ts index 1fa074b80..de5b9b8e5 100644 --- a/scripts/lib/cli-external-packages.test.ts +++ b/scripts/lib/cli-external-packages.test.ts @@ -7,11 +7,12 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import serverPackageJson from "../../apps/server/package.json" with { type: "json" }; + import { - CLI_EXTERNAL_PACKAGE_PREFIXES, - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, CLI_RUNTIME_EXTERNAL_PREFIXES, findInlinedExternalPackages, + selectCliRuntimeExternalDependencies, shouldBundleCliDependency, } from "./cli-external-packages.ts"; @@ -60,39 +61,41 @@ describe("shouldBundleCliDependency", () => { }); // The real package is `node-gyp-build-optional-packages`, reached by prefix. - // Matching it as external while failing to unpack it is invisible on the - // Windows primary (which reads app.asar) and breaks only under WSL. + // It is transitive to a selected dependency root, so the runtime closure test + // below ensures it follows that root into the sidecar. it("treats prefix-matched siblings as external", () => { assert.strictEqual(shouldBundleCliDependency("node-gyp-build-optional-packages"), false); }); }); -describe("CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS", () => { - it("unpacks every external prefix from both the top level and the pnpm store", () => { - for (const prefix of CLI_EXTERNAL_PACKAGE_PREFIXES) { - assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, `node_modules/${prefix}*/**/*`, prefix); - assert.include( - CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, - `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`, - prefix, - ); - } +describe("selectCliRuntimeExternalDependencies", () => { + it("keeps only runtime-external dependency roots for the Windows sidecar", () => { + assert.deepStrictEqual( + selectCliRuntimeExternalDependencies({ + "@effect/platform-bun": "1.0.0", + "@ff-labs/fff-node": "2.0.0", + effect: "3.0.0", + "node-pty": "4.0.0", + }), + { + "@ff-labs/fff-node": "2.0.0", + "node-pty": "4.0.0", + }, + ); }); - // Without the trailing `*` the globs stop covering prefix-matched siblings, - // which is exactly how a package ends up external but not unpacked. - it("keeps the trailing wildcard that matches prefix siblings", () => { - assert.include(CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS, "node_modules/node-gyp-build*/**/*"); + it("selects every external root declared by the server", () => { + assert.deepStrictEqual( + Object.keys(selectCliRuntimeExternalDependencies(serverPackageJson.dependencies)).sort(), + ["@ff-labs/fff-node", "msgpackr-extract", "node-pty"], + ); }); }); -// The failure this guards is invisible on Windows and fatal under WSL. -// // An external package is loaded from the real filesystem, so its own `require` // also resolves from the real filesystem. If one of its dependencies was -// bundled away instead of left external, that dependency exists only inside -// app.asar — which the Windows primary reads transparently under -// ELECTRON_RUN_AS_NODE, and plain `node` under WSL cannot. +// bundled away instead of left external, that dependency does not follow the +// selected root into the sidecar. // // Found the hard way: node-gyp-build-optional-packages requires detect-libc, // which was bundled. Windows was fine; WSL got MODULE_NOT_FOUND. @@ -103,8 +106,8 @@ it.layer(NodeServices.layer)("external package dependency closure", (it) => { // by name from this file at all, and an `exports` map can refuse the // `/package.json` subpath outright (@ff-labs/fff-node). Both surface as "not // installed", which would let this test skip everything and pass while - // checking nothing. The store is also what asarUnpack globs target, so this - // reads the same tree the build packages. + // checking nothing. The store contains the dependency graph the sidecar's + // minimal production install resolves. const readInstalledPackages = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/scripts/lib/cli-external-packages.ts b/scripts/lib/cli-external-packages.ts index f50718af4..d7a89bc40 100644 --- a/scripts/lib/cli-external-packages.ts +++ b/scripts/lib/cli-external-packages.ts @@ -4,14 +4,12 @@ * Two consumers derive from this list, and they must never disagree: * * - apps/server/vite.config.ts decides what stays external to the bundle. - * - scripts/build-desktop-artifact.ts decides what gets unpacked out of the asar. + * - scripts/build-desktop-artifact.ts selects the runtime dependency roots for + * the Windows server sidecar. * - * A package that is external but not unpacked still resolves on the Windows - * primary, which runs under ELECTRON_RUN_AS_NODE and reads app.asar - * transparently. It fails only under WSL, where the backend is launched as plain - * `wsl.exe -- node` and cannot read inside an archive. That asymmetry makes the - * drift invisible on the platform you are most likely to test on, which is why - * both consumers derive from one list instead of maintaining their own. + * A runtime package that is external but absent from the sidecar fails as soon + * as Node resolves it from the emitted bundle. Keeping both consumers on one + * list prevents packaging from drifting away from the bundle boundary. * * Entries are matched as prefixes (`id.startsWith(prefix)`), so they also cover * a package's platform-specific siblings — `node-gyp-build` covers @@ -24,8 +22,8 @@ * critically — the ordinary JS packages those wrappers require. An external * package is loaded from the real filesystem, so its own `require` also * resolves from the real filesystem; a dependency that was bundled away exists - * only inside app.asar and is unreachable there. This closure is enforced by a - * test, not by inspection. + * only inside the emitted bundle and is unreachable there. This closure is + * enforced by a test, not by inspection. */ export const CLI_RUNTIME_EXTERNAL_PREFIXES = [ "node-pty", @@ -70,6 +68,10 @@ export const CLI_EXTERNAL_PACKAGE_PREFIXES = [ ...CLI_BUILD_ONLY_EXTERNAL_PREFIXES, ] as const; +export function isRuntimeExternalCliDependency(id: string): boolean { + return CLI_RUNTIME_EXTERNAL_PREFIXES.some((prefix) => id.startsWith(prefix)); +} + /** * True when `id` must stay out of the bundle. * @@ -90,20 +92,14 @@ export function shouldBundleCliDependency(id: string): boolean { return !isExternalCliDependency(id); } -/** - * asar-unpack globs covering every external package. - * - * The trailing `*` is what keeps these aligned with the prefix matching above: - * without it, `node-gyp-build` would be left external by the bundler and then - * not unpacked, because the real package is `node-gyp-build-optional-packages`. - * - * pnpm stores real files under `.pnpm` and symlinks the top-level names, so both - * paths are unpacked for the link target to exist on disk. - */ -export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.flatMap( - (prefix) => - [`node_modules/${prefix}*/**/*`, `node_modules/.pnpm/**/node_modules/${prefix}*/**/*`] as const, -); +/** Select direct dependency roots whose runtime closure belongs in the sidecar. */ +export function selectCliRuntimeExternalDependencies( + dependencies: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(dependencies).filter(([name]) => isRuntimeExternalCliDependency(name)), + ); +} /** * Scan an emitted bundle chunk for runtime-external packages that were inlined. @@ -122,8 +118,8 @@ export const CLI_EXTERNAL_PACKAGE_UNPACK_GLOBS = CLI_EXTERNAL_PACKAGE_PREFIXES.f * check the opposite direction too. Verifying only that externals are absent * would still pass if the bundler reverted to leaving everything external: the * scan would see source-file regions, report nothing inlined, and the packaged - * WSL backend would then fail with ERR_MODULE_NOT_FOUND because those packages - * are not in the unpack globs either. + * backends would then fail with ERR_MODULE_NOT_FOUND because those packages + * are not in the selected sidecar closure either. */ export function findInlinedExternalPackages(source: string): { readonly regionCount: number; diff --git a/scripts/package.json b/scripts/package.json index 457a8f0d3..14c4ea98e 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@electron/asar": "^3.4.1", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", From 4905a5aefd8d104ee3642cf6bbb356bb7882a84e Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:43:33 +0000 Subject: [PATCH 02/99] fix(web): style sidebar action tooltips (#6371) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> (cherry picked from commit 196c8ea0d642acd1db66bd57f5d98abe81d8da6e) --- apps/web/src/components/Sidebar.tsx | 82 ++++++++++++++++++----------- 1 file changed, 51 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 011f16155..c58c778a9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -365,19 +365,26 @@ function SnoozePopoverButton(props: { ); return ( - event.stopPropagation()} - onDoubleClick={(event) => event.stopPropagation()} - className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" - /> - } - > - - + + event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + className="inline-flex h-full cursor-pointer items-center gap-0.5 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground hover:text-foreground" + /> + } + /> + } + > + + + Snooze thread + {presets.map((preset) => ( + + + } + > + + + Unpin thread + ) : ( ) : null} {props.settlementSupported ? ( - + + + } + > + + Settle + + Settle thread + ) : null} ) : null} From c0848aebb02ea045c5930132bab61b30e9f81fb6 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:30:13 +0200 Subject: [PATCH 03/99] refactor(web): simplify global styling (#6381) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Julius Marminge (cherry picked from commit 9885a845c97325b1099b095011da8385485616f5) --- apps/web/src/components/AgentsPanel.tsx | 18 +- .../BranchToolbarBranchSelector.tsx | 9 +- apps/web/src/components/ChatMarkdown.tsx | 23 +- apps/web/src/components/ChatView.tsx | 18 +- .../src/components/ComposerPromptEditor.tsx | 12 +- apps/web/src/components/DiffPanel.tsx | 9 +- apps/web/src/components/DiffPanelShell.tsx | 2 +- apps/web/src/components/LegacySidebar.tsx | 7 +- .../src/components/NoActiveThreadState.tsx | 4 +- apps/web/src/components/RightPanelTabs.tsx | 13 +- apps/web/src/components/Sidebar.tsx | 13 +- .../src/components/ThreadTerminalDrawer.tsx | 9 +- .../components/chat/ComposerCommandMenu.tsx | 2 +- .../ComposerPreviewAnnotationCards.test.tsx | 14 + .../chat/ComposerPreviewAnnotationCards.tsx | 10 +- .../components/chat/ComposerStashBadge.tsx | 2 +- .../src/components/chat/ComposerStashMenu.tsx | 2 +- .../components/chat/ContextWindowMeter.tsx | 14 +- .../components/chat/MessagesTimeline.test.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 2 +- .../components/chat/ModelPickerContent.tsx | 11 +- .../components/chat/ProviderStatusBanner.tsx | 10 +- apps/web/src/components/composerInlineChip.ts | 3 + .../diffs/StyledDiffCodeView.test.tsx | 4 +- .../components/diffs/StyledDiffCodeView.tsx | 4 +- .../src/components/files/FileBrowserPanel.tsx | 7 +- .../src/components/files/FilePreviewPanel.tsx | 5 +- .../components/preview/PreviewChromeRow.tsx | 8 +- .../pullRequest/PullRequestCodeTab.tsx | 24 +- .../pullRequest/PullRequestDetailPanel.tsx | 10 +- .../pullRequest/PullRequestListFilters.tsx | 26 +- .../pullRequest/PullRequestReviewerPicker.tsx | 5 +- .../search/ProjectContentSearchDialog.tsx | 15 +- .../settings/DiagnosticsSettings.tsx | 26 +- .../settings/KeybindingsSettings.tsx | 98 +- .../settings/ProjectSettingsPanel.tsx | 2 +- .../settings/ProviderInstanceCard.tsx | 9 +- .../settings/ProviderModelsSection.tsx | 33 +- .../settings/ProviderSettingsPanel.tsx | 10 +- .../settings/ResourceTelemetryDiagnostics.tsx | 14 +- .../settings/SettingsSidebarNav.tsx | 4 +- .../settings/SourceControlSettings.tsx | 45 +- .../components/settings/ThemeImportDialog.tsx | 8 +- .../settings/ThemeSearchSection.tsx | 30 +- .../components/settings/settingsLayout.tsx | 18 +- .../src/components/sidebar/SidebarChrome.tsx | 2 +- .../sidebar/SidebarProviderUpdatePill.tsx | 12 +- .../src/components/threadSidebarWidth.test.ts | 19 +- apps/web/src/components/ui/button.test.tsx | 15 + apps/web/src/components/ui/button.tsx | 8 + apps/web/src/components/ui/combobox.tsx | 2 +- apps/web/src/components/ui/input-group.tsx | 4 +- apps/web/src/components/ui/input.tsx | 6 +- apps/web/src/components/ui/menu.tsx | 10 +- apps/web/src/components/ui/popover.tsx | 2 + apps/web/src/components/ui/scroll-area.tsx | 19 +- apps/web/src/components/ui/select.tsx | 4 +- apps/web/src/components/ui/sidebar.tsx | 2 + apps/web/src/components/ui/skeleton.tsx | 2 +- apps/web/src/components/ui/toast.tsx | 27 +- apps/web/src/components/ui/toggle.tsx | 2 + apps/web/src/components/usage/UsagePage.tsx | 11 +- apps/web/src/index.css | 1101 ++++++----------- .../web/src/routes/-chatIndexTitlebar.test.ts | 7 +- apps/web/src/routes/_chat.index.tsx | 2 +- apps/web/src/routes/_chat.pull-requests.tsx | 7 +- apps/web/src/routes/settings.tsx | 2 +- apps/web/src/terminal/ghostty/surface.ts | 9 +- pnpm-lock.yaml | 14 +- 69 files changed, 773 insertions(+), 1132 deletions(-) diff --git a/apps/web/src/components/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 0b72d0ac3..e37611927 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -49,6 +49,7 @@ import { cn } from "~/lib/utils"; import { orchestrationEnvironment } from "~/state/orchestration"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Button } from "~/components/ui/button"; +import { Button } from "~/components/ui/button"; import { AlertDialog, AlertDialogClose, @@ -404,14 +405,15 @@ function WorkflowScriptView({ {scriptPath.split("/").at(-1)} - +
{result._tag === "Success" ? ( @@ -561,14 +563,14 @@ function ExpandedWorkflowSection({ {settled}/{members.length} settled - +
{scriptOpen && canShowScript ? ( diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 05ed533ac..b3c1c08eb 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -51,6 +51,7 @@ import { } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; +import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { Combobox, ComboboxEmpty, @@ -814,9 +815,11 @@ export function BranchToolbarBranchSelector({ maybeFetchNextBranchPage(); }} className={cn( - "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", - showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1", + getVirtualizedScrollFadeClassName({ + top: showTopBranchScrollFade, + bottom: showBottomBranchScrollFade, + }), )} style={{ maxHeight: "14rem" }} /> diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e9390ed0a..53b043f3a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -449,7 +449,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { {children} -
+
-
- +
+ (); const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); return ( - + {failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( @@ -1044,7 +1047,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(plainText); return ( <> - + {plainText.slice(0, leadingLength)} @@ -1060,7 +1063,7 @@ function MarkdownExternalLinkContent({ const leadingLength = leadingExternalLinkTextLength(firstChild); return ( <> - + {firstChild.slice(0, leadingLength)} @@ -1072,7 +1075,7 @@ function MarkdownExternalLinkContent({ return ( <> - + {firstChild} @@ -1289,7 +1292,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ side="top" className="max-w-[min(40rem,calc(100vw-2rem))] font-mono text-[11px] leading-tight" > -
+
{displayPath}
@@ -1699,7 +1702,7 @@ function ChatMarkdown({ return (
{rightPanelOpen && !shouldUseRightPanelSheet ? ( @@ -7245,16 +7246,17 @@ function ChatViewContent(props: ChatViewProps) { className="pointer-events-none absolute left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" style={{ bottom: composerOverlayHeight + 4 }} > - +
)}
@@ -7271,7 +7273,7 @@ function ChatViewContent(props: ChatViewProps) { >
{isDraftHeroState ? ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 0489e8c79..f6dfef248 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -71,6 +71,7 @@ import { import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { + COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME, @@ -188,7 +189,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -326,7 +327,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -397,7 +398,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "composer-inline-chip relative inline-flex align-[-0.125em] leading-none"; + dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } @@ -1747,13 +1748,12 @@ function ComposerPromptEditorInner({ return ( -
+
Appearance - // can drive it; keep everything else here. + // The wrapper owns the appearance preference; keep everything else here. "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", className, )} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 385d67b6b..b929d05a7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -825,7 +825,7 @@ export default function DiffPanel({
) : ( <> -
+
{isSelectedPatchTruncated && (

This diff was truncated because it exceeded the preview limit. The changes shown are @@ -907,10 +907,11 @@ export default function DiffPanel({ } diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 68a5855c1..82dddd8f4 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -11,7 +11,9 @@ export function NoActiveThreadState() {

diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 70984ffba..7c28f209f 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -26,6 +26,7 @@ import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; +import { Button } from "~/components/ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; @@ -605,7 +606,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { >
0 ? ( + } > diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c58c778a9..43001a85e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3300,9 +3300,9 @@ export default function Sidebar() { {isSearchingThreads ? ( + ); })} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 87f0ed4ae..1266e5ed7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -31,6 +31,7 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { Button } from "~/components/ui/button"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -1273,13 +1274,9 @@ export default function ThreadTerminalDrawer({ ) : null}

No terminal sessions for this thread yet.

- +
); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 3ed2a9432..4f32211c1 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -141,7 +141,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { >
{props.items.length > 0 ? ( diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx index 5bb28054e..46af29907 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx @@ -43,4 +43,18 @@ describe("ComposerPreviewAnnotationCards", () => { expect(markup).not.toContain("localhost:3000"); expect(markup).not.toContain("Preview annotation"); }); + + it("uses the shared button contract for removal", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('aria-label="Remove preview annotation"'); + expect(markup).toContain('data-slot="button"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx index 5e9e43dcf..19f6a5ca3 100644 --- a/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx +++ b/apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx @@ -5,6 +5,7 @@ import type { ReactNode } from "react"; import type { ComposerImageAttachment } from "~/composerDraftStore"; import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; interface ComposerPreviewAnnotationCardsProps { annotations: ReadonlyArray; @@ -128,14 +129,15 @@ export function ComposerPreviewAnnotationCards({
- + ); })} diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx index 79ed301a5..a2599ebc9 100644 --- a/apps/web/src/components/chat/ComposerStashBadge.tsx +++ b/apps/web/src/components/chat/ComposerStashBadge.tsx @@ -46,7 +46,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { className={cn( "rounded-full px-1.5 text-[10px] font-medium tabular-nums", props.pulsing - ? "prompt-stash-count-enter bg-primary text-primary-foreground" + ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] bg-primary text-primary-foreground motion-reduce:animate-none" : "bg-muted text-muted-foreground", )} > diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 9e9238515..fc8be327d 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -91,7 +91,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { return ( -
+
diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index b77effe58..cbb4f0eb8 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -1,6 +1,5 @@ import type { SessionCompactionUpdatedPayload } from "@t3tools/contracts"; import { useId } from "react"; -import { cn } from "~/lib/utils"; import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -162,13 +161,10 @@ export function ContextWindowMeter(props: { delay={150} closeDelay={0} render={ - + } /> { ); expect(compactMarkup).toContain('class="h-3 sm:h-4"'); - expect(compactMarkup).not.toContain("chat-timeline-scroll-fade"); + expect(compactMarkup).not.toContain("topbar-scroll-fade"); expect(fadedMarkup).toContain('class="h-10 sm:h-12"'); - expect(fadedMarkup).toContain("chat-timeline-scroll-fade"); + expect(fadedMarkup).toContain("topbar-scroll-fade"); }); it("keeps assistant changed-files headers sticky below the thread header", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4161f1ab3..89331403f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -600,7 +600,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onScroll={handleScroll} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", - topFadeEnabled && "chat-timeline-scroll-fade", + topFadeEnabled && "topbar-scroll-fade", )} ListHeaderComponent={ loadEarlier !== null ? ( diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 7c86ec630..7ffb2bf07 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -33,6 +33,7 @@ import { } from "../../keybindings"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; import { TooltipProvider } from "../ui/tooltip"; import { isProviderInstancePickerReady, @@ -598,7 +599,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return (
{/* Sidebar */} @@ -781,9 +782,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "model-picker-list scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", - showTopScrollFade && "model-picker-list-scroll-fade-top", - showBottomScrollFade && "model-picker-list-scroll-fade-bottom", + "scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [&::-webkit-scrollbar-track]:my-2", + getVirtualizedScrollFadeClassName({ + top: showTopScrollFade, + bottom: showBottomScrollFade, + }), )} /> diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index f82c17b13..1c7571b96 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -2,6 +2,7 @@ import { type ServerProvider } from "@t3tools/contracts"; import { memo } from "react"; import { InfoIcon, XIcon } from "lucide-react"; import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; import { formatProviderDriverKindLabel } from "../../providerModels"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -66,14 +67,15 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
- +
); diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index c17b3ddab..3f0e8ca1a 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -8,6 +8,9 @@ export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`; +export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME = + "relative inline-flex align-[-0.125em] leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']"; + export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index dbdd10d19..f0cd49abc 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -35,7 +35,9 @@ describe("StyledDiffCodeView", () => { />, ); - expect(testState.codeViewClassName).toBe("diff-render-surface outline-none min-h-0"); + expect(testState.codeViewClassName).toBe( + "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", + ); expect(testState.codeViewOptions).toMatchObject({ theme: "pierre-dark", stickyHeaders: true, diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index 7dbd5358a..14939de09 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -292,8 +292,8 @@ export function StyledDiffCodeView({ // outside the panel clipping boundary; actual controls inside retain their own indicators. className={ className - ? `diff-render-surface outline-none ${className}` - : "diff-render-surface outline-none" + ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}` + : "diff-render-surface [--code-background:var(--background)] outline-none" } options={{ ...options, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ff658693a..e3280c99c 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -78,7 +78,7 @@ function FileSearchField(props: { value: string; }) { return ( - + -
+
{relativePath ? ( -
+
-
+
- + { event.stopPropagation(); toggleFile(item.id); @@ -685,7 +684,7 @@ export function PullRequestCodeTab({ ) : ( )} - + ); }, [toggleFile], @@ -899,7 +898,7 @@ export function PullRequestCodeTab({ review.verdicts.length === 0 ? null : (
{reviewOpen ? ( -
+
+ )}
); @@ -959,7 +959,7 @@ export function PullRequestCodeTab({ * diff API offers it. */ const toolbar = ( -
+
{/* A host that reports no commits has nothing to scope by, and a dropdown whose only entry is the scope already showing is a control that does nothing. */} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 7237b4357..2f4e84dc3 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1137,8 +1137,14 @@ export function PullRequestDetailPanel({ <> + } > diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index dd4a9d161..3066eafc3 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -24,6 +24,7 @@ import type { ElementType } from "react"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; import { Menu, @@ -79,29 +80,18 @@ export function PullRequestSearchInput({ onChange: (value: string) => void; }) { return ( -
- {busy ? ( - - ) : ( - - )} - + + {busy ? : } + + onChange(event.currentTarget.value)} placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" - // Tracks the shared input's height at both widths, so it stays level with the icon - // button beside it rather than towering over it on wide screens. - className="h-9 w-full rounded-lg border border-input bg-background pr-3 pl-9 text-sm outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24 sm:h-8" /> -
+ ); } diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index 25c663794..8330c87a9 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -19,6 +19,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; @@ -131,13 +132,13 @@ export function PullRequestReviewerPicker({ />
- setQuery(event.currentTarget.value)} placeholder="Search people with access" aria-label="Search people with access" - className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring" + size="compact" />
diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 1890aab7f..d877d6537 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -11,6 +11,7 @@ import { useProjectContentSearch } from "~/state/queries"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { CommandPaletteContent } from "../CommandPaletteContent"; import { ScrollArea } from "../ui/scroll-area"; +import { Toggle } from "../ui/toggle"; import { HighlightedSearchLine } from "./HighlightedSearchLine"; interface ProjectContentSearchDialogProps { @@ -58,19 +59,17 @@ function SearchOptionButton(props: { readonly children: ReactNode; }) { return ( - + ); } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 5bd4fdc08..a472c6a8d 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -273,14 +273,14 @@ function TraceIdCell({ traceId }: { traceId: string }) { copyToClipboard(traceId)} > - + } /> {copied ? "Copied" : "Copy full trace ID"} @@ -322,14 +322,14 @@ function ProcessNameCell({ style={{ paddingLeft: `${Math.min(process.depth, 6) * 10}px` }} > {hasChildren ? ( - + ) : (
- - @@ -702,17 +681,11 @@ function WhenExpressionBuilder({ ) : (
- - @@ -864,8 +837,7 @@ function KeybindingTableRow({ )} {isDirty ? (
+ ) : ( )} @@ -975,9 +975,8 @@ export function ResourceTelemetryDiagnostics() { - } - /> - - {children} - - - ); -} - function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); } @@ -316,9 +301,8 @@ function DiscoveryItemRow({
{hasDetails ? ( + } /> @@ -216,11 +212,10 @@ export function SettingResetButton({ { event.stopPropagation(); onClick(); @@ -251,7 +246,10 @@ export function SettingsPageContainer({ return ( -
+
{children}
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index c77fab0e0..d9171a500 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -84,7 +84,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { startExit(displayedView.key, null, displayedView.key)} > - + } /> Dismiss until provider status changes diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index 3beb2a8f5..e38d5c374 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares shipped CSS with the sidebar width contract. +// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the sidebar component with its width contract. import * as NodeFS from "node:fs"; import { describe, expect, it } from "vite-plus/test"; @@ -36,20 +36,13 @@ describe("thread sidebar width", () => { }); it("shows the desktop wordmark across the sidebar's full legal width range", () => { - const sidebarStyles = NodeFS.readFileSync(new URL("../index.css", import.meta.url), "utf8"); - const desktopHeaderStyles = sidebarStyles.slice( - sidebarStyles.indexOf("@media (min-width: 48rem)"), - sidebarStyles.indexOf("/* Stage-channel sidebar art"), + const sidebarSource = NodeFS.readFileSync( + new URL("./sidebar/SidebarChrome.tsx", import.meta.url), + "utf8", ); - const stageLabelThreshold = desktopHeaderStyles.match( - /@container sidebar-header \(min-width: ([\d.]+)rem\) \{\s*\.sidebar-brand-stage \{\s*display: inline-flex;/, - )?.[1]; - expect(sidebarStyles).toMatch(/\.sidebar-brand \{\s*display: none;/); - expect(desktopHeaderStyles).toMatch( - /@media \(min-width: 48rem\) \{\s*\.sidebar-brand \{\s*display: flex;/, - ); + expect(sidebarSource).toContain("hidden h-7 w-fit min-w-0 shrink-0 items-center gap-1"); + expect(sidebarSource).toContain("md:flex"); expect(THREAD_SIDEBAR_MIN_WIDTH).toBe(13 * 16); - expect(Number(stageLabelThreshold) * 16).toBeGreaterThan(THREAD_SIDEBAR_MIN_WIDTH); }); }); diff --git a/apps/web/src/components/ui/button.test.tsx b/apps/web/src/components/ui/button.test.tsx index 341d85b42..e1bd89d94 100644 --- a/apps/web/src/components/ui/button.test.tsx +++ b/apps/web/src/components/ui/button.test.tsx @@ -28,4 +28,19 @@ describe("button geometry tokens", () => { expect(html).toContain("size-7"); expect(html).toContain("sm:size-6"); }); + + it("owns shared compact and micro control geometry", () => { + const compact = renderToStaticMarkup(); + const micro = renderToStaticMarkup( + , + ); + + expect(compact).toContain("h-7"); + expect(compact).toContain("rounded-md"); + expect(micro).toContain("size-5"); + expect(micro).toContain("rounded-sm"); + expect(micro).toContain("text-muted-foreground"); + }); }); diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 778574ba0..9f0b4d049 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -16,9 +16,13 @@ const buttonVariants = cva( }, variants: { size: { + compact: + "h-7 gap-1 rounded-md px-[calc(--spacing(2)-1px)] text-xs before:rounded-[calc(var(--radius-md)-1px)] [&_svg:not([class*='size-'])]:size-3.5", default: "h-9 px-[calc(--spacing(3)-1px)] sm:h-8", icon: "size-9 sm:size-8", "icon-lg": "size-10 sm:size-9", + "icon-micro": + "size-5 rounded-sm p-0 before:rounded-[calc(var(--radius-sm)-1px)] [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-8 sm:size-7", "icon-xl": "size-11 sm:size-10 [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5", @@ -38,6 +42,10 @@ const buttonVariants = cva( "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:border-destructive/32 [:hover,[data-pressed]]:bg-destructive/4", ghost: "[--control-icon-color:var(--muted-foreground)] border-transparent text-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent", + "ghost-muted": + "[--control-icon-color:var(--muted-foreground)] border-transparent text-muted-foreground data-pressed:bg-accent [:hover,[data-pressed]]:bg-accent [:hover,[data-pressed]]:text-foreground", + glass: + "surface-glass [--control-icon-color:var(--muted-foreground)] border-border/60 text-foreground shadow-sm [:hover,[data-pressed]]:border-border", link: "border-transparent underline-offset-4 [:hover,[data-pressed]]:underline", outline: "[--control-icon-color:var(--muted-foreground)] border-input bg-popover not-dark:bg-clip-padding text-foreground shadow-xs/5 not-disabled:not-active:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-disabled:before:shadow-[0_-1px_--theme(--color-white/2%)] dark:not-disabled:not-active:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [:disabled,:active,[data-pressed]]:shadow-none [:hover,[data-pressed]]:bg-accent/50 dark:[:hover,[data-pressed]]:bg-input/64", diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 324b67e64..cf3a46142 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -170,7 +170,7 @@ function ComboboxPopup({ > diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx index 2ac9ee1ed..04e34e561 100644 --- a/apps/web/src/components/ui/input-group.tsx +++ b/apps/web/src/components/ui/input-group.tsx @@ -8,7 +8,7 @@ import { Input, type InputProps } from "~/components/ui/input"; import { Textarea, type TextareaProps } from "~/components/ui/textarea"; const inputGroupVariants = cva( - "relative inline-flex w-full min-w-0 items-center rounded-lg border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--radius-md)-1px)]", + "relative inline-flex w-full min-w-0 items-center rounded-[var(--control-radius)] border text-base text-foreground ring-ring/24 transition-shadow has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/64 has-[input:focus-visible,textarea:focus-visible]:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/16 has-[textarea]:h-auto has-data-[align=block-end]:h-auto has-data-[align=block-start]:h-auto has-data-[align=block-end]:flex-col has-data-[align=block-start]:flex-col has-[input:focus-visible,textarea:focus-visible]:border-ring has-[input[aria-invalid],textarea[aria-invalid]]:border-destructive/36 has-autofill:bg-foreground/4 has-[input:disabled,textarea:disabled]:opacity-64 has-[input:disabled,textarea:disabled,input:focus-visible,textarea:focus-visible,input[aria-invalid],textarea[aria-invalid]]:shadow-none has-[input:focus-visible,textarea:focus-visible]:ring-[3px] sm:text-sm dark:has-autofill:bg-foreground/8 dark:has-[input[aria-invalid],textarea[aria-invalid]]:ring-destructive/24 has-data-[align=inline-start]:**:[[data-size=sm]_input]:ps-1.5 has-data-[align=inline-end]:**:[[data-size=sm]_input]:pe-1.5 *:[[data-slot=input-control],[data-slot=textarea-control]]:contents *:[[data-slot=input-control],[data-slot=textarea-control]]:before:hidden has-[[data-align=block-start],[data-align=block-end]]:**:[input]:h-auto has-data-[align=inline-start]:**:[input]:ps-2 has-data-[align=inline-end]:**:[input]:pe-2 has-data-[align=block-end]:**:[input]:pt-1.5 has-data-[align=block-start]:**:[input]:pb-1.5 **:[textarea]:min-h-20.5 **:[textarea]:resize-none **:[textarea]:py-[calc(--spacing(3)-1px)] **:[textarea]:max-sm:min-h-23.5 **:[textarea_button]:rounded-[calc(var(--control-radius)-1px)]", { defaultVariants: { variant: "default", @@ -16,7 +16,7 @@ const inputGroupVariants = cva( variants: { variant: { default: - "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", + "border-input bg-background not-dark:bg-clip-padding shadow-xs/5 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--control-radius)-1px)] not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_1px_--theme(--color-black/4%)] dark:bg-input/32 dark:not-has-[input:disabled,textarea:disabled]:not-has-[input:focus-visible,textarea:focus-visible]:not-has-[input[aria-invalid],textarea[aria-invalid]]:before:shadow-[0_-1px_--theme(--color-white/6%)]", ghost: "border-transparent bg-transparent shadow-none hover:bg-muted/40 has-[input:focus-visible,textarea:focus-visible]:bg-background", }, diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 6edc8d4a6..cae3dfe62 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -6,7 +6,7 @@ import type * as React from "react"; import { cn } from "~/lib/utils"; type InputProps = Omit, "size"> & { - size?: "sm" | "default" | "lg" | number; + size?: "sm" | "compact" | "default" | "lg" | number; unstyled?: boolean; nativeInput?: boolean; }; @@ -20,6 +20,7 @@ function Input({ }: InputProps) { const inputClassName = cn( "h-8.5 w-full min-w-0 rounded-[inherit] px-[calc(--spacing(3)-1px)] leading-8.5 outline-none placeholder:text-placeholder sm:h-7.5 sm:leading-7.5 [transition:background-color_5000000s_ease-in-out_0s]", + size === "compact" && "h-7 px-[calc(--spacing(2.5)-1px)] text-xs leading-7 sm:h-7 sm:leading-7", size === "sm" && "h-7.5 px-[calc(--spacing(2.5)-1px)] leading-7.5 sm:h-6.5 sm:leading-6.5", size === "lg" && "h-9.5 leading-9.5 sm:h-8.5 sm:leading-8.5", props.type === "search" && @@ -59,6 +60,9 @@ function Input({ cn( !unstyled && "relative inline-flex w-full rounded-lg border border-input bg-background not-dark:bg-clip-padding text-base text-foreground shadow-xs/5 ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] sm:text-sm dark:bg-input/32 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]", + !unstyled && + size === "compact" && + "rounded-md before:rounded-[calc(var(--radius-md)-1px)]", className, ) || undefined } diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 9f7cfc8c0..803d6c198 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -36,6 +36,13 @@ function MenuPopup({ side?: MenuPrimitive.Positioner.Props["side"]; anchor?: MenuPrimitive.Positioner.Props["anchor"]; }) { + const hasExplicitWidthClass = + typeof className === "string" && + className.split(/\s+/).some((classToken) => { + const utility = classToken.split(":").at(-1) ?? classToken; + return /^(?:min-|max-)?w-/.test(utility); + }); + return (
) { return (
copyToClipboard(text)} - type="button" /> } > @@ -381,24 +382,22 @@ function ToastBodyContent({ > {copyErrorText !== null ? : null} {additionalActions.map(({ id, props: { className, ...props } }) => ( - ))}
- +
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index abd9dfc72..295a1c5b4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); +@custom-variant light (&:not(.dark, .dark *)); /* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ @custom-variant wco (&:is(.wco, .wco *)); @@ -102,20 +103,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --workspace-native-controls-inset: 0px; --workspace-titlebar-control-size: 1.75rem; --workspace-titlebar-control-gap: 0.75rem; -} - -.dark { - --app-scrollbar-thumb: rgb(255 255 255 / 8%); - --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); - --glass-blur: 16px; - --glass-saturation: 1.08; -} -[data-slot="sidebar-wrapper"] { - --workspace-titlebar-content-left: calc( - var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + - var(--workspace-titlebar-control-gap) - ); + @variant dark { + --app-scrollbar-thumb: rgb(255 255 255 / 8%); + --app-scrollbar-thumb-hover: rgb(255 255 255 / 12%); + --glass-blur: 16px; + --glass-saturation: 1.08; + } } .wco { @@ -317,6 +311,179 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } +@utility surface-glass { + background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--background) !important; + } +} + +@utility alert-glass { + --alert-glass-tint: transparent; + background: + linear-gradient( + color-mix(in srgb, var(--alert-glass-tint) 4%, transparent), + color-mix(in srgb, var(--alert-glass-tint) 4%, transparent) + ), + color-mix(in srgb, var(--background) var(--glass-opacity), transparent) !important; + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + + &[data-variant="error"] { + --alert-glass-tint: var(--destructive); + } + + &[data-variant="info"] { + --alert-glass-tint: var(--info); + } + + &[data-variant="success"] { + --alert-glass-tint: var(--success); + } + + &[data-variant="warning"] { + --alert-glass-tint: var(--warning); + } + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--background) !important; + } +} + +@utility dialog-glass { + background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border-color: color-mix(in srgb, var(--foreground) 10%, transparent); + box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%); + + @variant dark { + border-color: color-mix(in srgb, var(--color-white) 8%, transparent); + box-shadow: + inset 0 1px rgb(255 255 255 / 4%), + 0 24px 72px -20px rgb(0 0 0 / 90%); + } + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--popover) !important; + } +} + +@utility dialog-backdrop { + background: color-mix(in srgb, var(--background) 60%, transparent); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); + + @variant dark { + background: color-mix(in srgb, var(--background) 64%, transparent); + } +} + +@utility dropdown-glass { + background: color-mix( + in srgb, + var(--popover) 18%, + color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) + ); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + + @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { + background: var(--popover) !important; + } +} + +@utility topbar-scroll-fade { + --topbar-scroll-fade-height: 2.5rem; + -webkit-mask-image: + linear-gradient( + to bottom, + transparent 0%, + rgb(0 0 0 / 10%) 10%, + rgb(0 0 0 / 30%) 24%, + rgb(0 0 0 / 58%) 42%, + rgb(0 0 0 / 82%) 62%, + rgb(0 0 0 / 96%) 82%, + black 100% + ), + linear-gradient(black, black), linear-gradient(black, black); + -webkit-mask-position: top, bottom, right; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: + 100% var(--topbar-scroll-fade-height), + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; + mask-image: + linear-gradient( + to bottom, + transparent 0%, + rgb(0 0 0 / 10%) 10%, + rgb(0 0 0 / 30%) 24%, + rgb(0 0 0 / 58%) 42%, + rgb(0 0 0 / 82%) 62%, + rgb(0 0 0 / 96%) 82%, + black 100% + ), + linear-gradient(black, black), linear-gradient(black, black); + mask-position: top, bottom, right; + mask-repeat: no-repeat; + mask-size: + 100% var(--topbar-scroll-fade-height), + 100% calc(100% - var(--topbar-scroll-fade-height)), + var(--app-scrollbar-width) 100%; + + @variant sm { + --topbar-scroll-fade-height: 3rem; + } +} + +/* Virtualizers own their native scroll element, so they cannot use ScrollArea's + viewport fade. Keep the scrollbar lane opaque while sharing the same fade + contract across those lists. */ +@utility virtualized-scroll-fade { + -webkit-mask-image: var(--virtualized-scroll-fade-mask), linear-gradient(black, black); + mask-image: var(--virtualized-scroll-fade-mask), linear-gradient(black, black); + -webkit-mask-position: left, right; + mask-position: left, right; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; + mask-size: + calc(100% - var(--app-scrollbar-width)) 100%, + var(--app-scrollbar-width) 100%; +} + +/* Stage-channel art needs a mask and pseudo-element gradient, so keep the + behavior composable without tying it to the global components layer. */ +@utility sidebar-stage-backdrop { + --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); + mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); + + &::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + transparent 0%, + transparent 28%, + color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, + color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, + color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, + color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, + color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, + var(--stage-fade) 93% + ); + } +} + @layer base { :root { /* The inherited artwork palettes, kept as the defaults. Built-in themes @@ -345,12 +512,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-glow-highlight: oklch(0.553749 0.176543 271.958); --stage-night-glow-secondary: oklch(0.345571 0.117466 273.568); --stage-night-sparkle: oklch(0.880867 0.057747 269.011); - } - .dark { - --stage-art-top: oklch(0.581473 0.149124 256.9); - --stage-art-mid: oklch(0.456509 0.159377 261.945); - --stage-art-bottom: oklch(0.291327 0.136578 267.649); + @variant dark { + --stage-art-top: oklch(0.581473 0.149124 256.9); + --stage-art-mid: oklch(0.456509 0.159377 261.945); + --stage-art-bottom: oklch(0.291327 0.136578 267.649); + } } * { @@ -364,63 +531,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ):focus-visible { @apply outline-none ring-0; } - html { + html, + body { background-color: var(--app-chrome-background); } body { @apply text-foreground relative; - background-color: var(--app-chrome-background); } } @layer components { - .sidebar-brand { - display: none; - } - - .sidebar-brand-stage { - display: none; - } - - @media (min-width: 48rem) { - .sidebar-brand { - display: flex; - } - - @container sidebar-header (min-width: 15.75rem) { - .sidebar-brand-stage { - display: inline-flex; - } - } - } - - /* Stage-channel sidebar art; ::after ramps to the sidebar bg color and the - mask lets the surface grain show through at the boundary. Panels whose - background differs from the app chrome (e.g. sidebar v2) override - --sidebar-stage-fade so the art fades into their own surface color. */ - .sidebar-stage-backdrop { - --stage-fade: var(--sidebar-stage-fade, var(--app-chrome-background)); - mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black 55%, transparent 92%); - } - - .sidebar-stage-backdrop::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - transparent 0%, - transparent 28%, - color-mix(in srgb, var(--stage-fade) 10%, transparent) 40%, - color-mix(in srgb, var(--stage-fade) 30%, transparent) 52%, - color-mix(in srgb, var(--stage-fade) 58%, transparent) 64%, - color-mix(in srgb, var(--stage-fade) 82%, transparent) 75%, - color-mix(in srgb, var(--stage-fade) 96%, transparent) 85%, - var(--stage-fade) 93% - ); - } - /* Each maintainer palette gives the same line art its own material: rose vellum, forest drafting paper, marine cyanotype, copper, and violet ink. These colors stay deliberately deep at the top edge so the white stage @@ -433,16 +553,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.763402 0.163836 352.525); --stage-art-tertiary: oklch(0.70819 0.180285 311.949); --stage-art-line: oklch(0.952158 0.034194 336.179); - } - html.dark[data-theme-id="t3-chat"] { - --stage-art-top: oklch(0.540689 0.143665 347.587); - --stage-art-mid: oklch(0.396586 0.126592 347.6); - --stage-art-bottom: oklch(0.249959 0.079694 340.523); - --stage-art-highlight: oklch(0.921297 0.051708 343.229); - --stage-art-secondary: oklch(0.667398 0.165674 352.549); - --stage-art-tertiary: oklch(0.609315 0.163722 306.315); - --stage-art-line: oklch(0.945349 0.036045 341.433); + @variant dark { + --stage-art-top: oklch(0.540689 0.143665 347.587); + --stage-art-mid: oklch(0.396586 0.126592 347.6); + --stage-art-bottom: oklch(0.249959 0.079694 340.523); + --stage-art-highlight: oklch(0.921297 0.051708 343.229); + --stage-art-secondary: oklch(0.667398 0.165674 352.549); + --stage-art-tertiary: oklch(0.609315 0.163722 306.315); + --stage-art-line: oklch(0.945349 0.036045 341.433); + } } html[data-theme-id="grove"] { @@ -460,23 +580,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-secondary: oklch(0.665652 0.109731 156.599); --stage-night-tertiary: oklch(0.698651 0.103024 89.828); --stage-night-line: oklch(0.945336 0.041923 157.222); - } - html.dark[data-theme-id="grove"] { - --stage-art-top: oklch(0.58719 0.09869 157.426); - --stage-art-mid: oklch(0.454979 0.079031 159.756); - --stage-art-bottom: oklch(0.297856 0.050355 161.167); - --stage-art-highlight: oklch(0.952407 0.053872 158.44); - --stage-art-secondary: oklch(0.732591 0.120606 155.853); - --stage-art-tertiary: oklch(0.716282 0.116547 80.563); - --stage-art-line: oklch(0.961577 0.035285 157.03); - --stage-night-top: oklch(0.398632 0.065534 158.601); - --stage-night-mid: oklch(0.290561 0.049694 160.456); - --stage-night-bottom: oklch(0.210147 0.03173 169.818); - --stage-night-highlight: oklch(0.866303 0.057526 156.796); - --stage-night-secondary: oklch(0.586553 0.093722 157.365); - --stage-night-tertiary: oklch(0.6364 0.101769 82.985); - --stage-night-line: oklch(0.913292 0.035718 156.976); + @variant dark { + --stage-art-top: oklch(0.58719 0.09869 157.426); + --stage-art-mid: oklch(0.454979 0.079031 159.756); + --stage-art-bottom: oklch(0.297856 0.050355 161.167); + --stage-art-highlight: oklch(0.952407 0.053872 158.44); + --stage-art-secondary: oklch(0.732591 0.120606 155.853); + --stage-art-tertiary: oklch(0.716282 0.116547 80.563); + --stage-art-line: oklch(0.961577 0.035285 157.03); + --stage-night-top: oklch(0.398632 0.065534 158.601); + --stage-night-mid: oklch(0.290561 0.049694 160.456); + --stage-night-bottom: oklch(0.210147 0.03173 169.818); + --stage-night-highlight: oklch(0.866303 0.057526 156.796); + --stage-night-secondary: oklch(0.586553 0.093722 157.365); + --stage-night-tertiary: oklch(0.6364 0.101769 82.985); + --stage-night-line: oklch(0.913292 0.035718 156.976); + } } html[data-theme-id="ocean"] { @@ -487,16 +607,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.788391 0.090856 215.684); --stage-art-tertiary: oklch(0.76441 0.099607 187.893); --stage-art-line: oklch(0.976025 0.019647 212.543); - } - html.dark[data-theme-id="ocean"] { - --stage-art-top: oklch(0.59663 0.089167 233.427); - --stage-art-mid: oklch(0.461094 0.084904 243.478); - --stage-art-bottom: oklch(0.294818 0.05947 250.526); - --stage-art-highlight: oklch(0.952907 0.032224 221.27); - --stage-art-secondary: oklch(0.732079 0.09296 224.414); - --stage-art-tertiary: oklch(0.720885 0.095495 190.903); - --stage-art-line: oklch(0.961039 0.027355 219.756); + @variant dark { + --stage-art-top: oklch(0.59663 0.089167 233.427); + --stage-art-mid: oklch(0.461094 0.084904 243.478); + --stage-art-bottom: oklch(0.294818 0.05947 250.526); + --stage-art-highlight: oklch(0.952907 0.032224 221.27); + --stage-art-secondary: oklch(0.732079 0.09296 224.414); + --stage-art-tertiary: oklch(0.720885 0.095495 190.903); + --stage-art-line: oklch(0.961039 0.027355 219.756); + } } html[data-theme-id="ember"] { @@ -514,23 +634,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-secondary: oklch(0.641705 0.126508 44.376); --stage-night-tertiary: oklch(0.538694 0.129931 25.865); --stage-night-line: oklch(0.926348 0.046029 58.73); - } - html.dark[data-theme-id="ember"] { - --stage-art-top: oklch(0.597533 0.120694 43.455); - --stage-art-mid: oklch(0.437763 0.101287 34.86); - --stage-art-bottom: oklch(0.264269 0.055858 26.548); - --stage-art-highlight: oklch(0.929214 0.042638 55.801); - --stage-art-secondary: oklch(0.705592 0.137369 43.176); - --stage-art-tertiary: oklch(0.629583 0.158322 24.088); - --stage-art-line: oklch(0.945058 0.033906 58.824); - --stage-night-top: oklch(0.392352 0.081287 36.444); - --stage-night-mid: oklch(0.271305 0.056352 31.135); - --stage-night-bottom: oklch(0.182126 0.028154 27.774); - --stage-night-highlight: oklch(0.851007 0.061294 53.805); - --stage-night-secondary: oklch(0.560789 0.10645 42.953); - --stage-night-tertiary: oklch(0.476228 0.106656 24.165); - --stage-night-line: oklch(0.884931 0.046607 56.556); + @variant dark { + --stage-art-top: oklch(0.597533 0.120694 43.455); + --stage-art-mid: oklch(0.437763 0.101287 34.86); + --stage-art-bottom: oklch(0.264269 0.055858 26.548); + --stage-art-highlight: oklch(0.929214 0.042638 55.801); + --stage-art-secondary: oklch(0.705592 0.137369 43.176); + --stage-art-tertiary: oklch(0.629583 0.158322 24.088); + --stage-art-line: oklch(0.945058 0.033906 58.824); + --stage-night-top: oklch(0.392352 0.081287 36.444); + --stage-night-mid: oklch(0.271305 0.056352 31.135); + --stage-night-bottom: oklch(0.182126 0.028154 27.774); + --stage-night-highlight: oklch(0.851007 0.061294 53.805); + --stage-night-secondary: oklch(0.560789 0.10645 42.953); + --stage-night-tertiary: oklch(0.476228 0.106656 24.165); + --stage-night-line: oklch(0.884931 0.046607 56.556); + } } html[data-theme-id="iris"] { @@ -541,16 +661,16 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-art-secondary: oklch(0.745085 0.125892 298.647); --stage-art-tertiary: oklch(0.73066 0.167815 340.964); --stage-art-line: oklch(0.960278 0.024064 306.969); - } - html.dark[data-theme-id="iris"] { - --stage-art-top: oklch(0.57297 0.145973 295.185); - --stage-art-mid: oklch(0.419499 0.13752 292.131); - --stage-art-bottom: oklch(0.274235 0.095798 286.608); - --stage-art-highlight: oklch(0.916698 0.047206 300.224); - --stage-art-secondary: oklch(0.670994 0.13095 296.689); - --stage-art-tertiary: oklch(0.679357 0.165376 340.439); - --stage-art-line: oklch(0.940582 0.032921 299.076); + @variant dark { + --stage-art-top: oklch(0.57297 0.145973 295.185); + --stage-art-mid: oklch(0.419499 0.13752 292.131); + --stage-art-bottom: oklch(0.274235 0.095798 286.608); + --stage-art-highlight: oklch(0.916698 0.047206 300.224); + --stage-art-secondary: oklch(0.670994 0.13095 296.689); + --stage-art-tertiary: oklch(0.679357 0.165376 340.439); + --stage-art-line: oklch(0.940582 0.032921 299.076); + } } :is(html[data-theme-id="t3-chat"], html[data-theme-id="ocean"], html[data-theme-id="iris"]) { @@ -591,65 +711,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --stage-night-sparkle: var(--stage-night-line); } - .workspace-topbar { - display: flex; - height: var(--workspace-topbar-height); - min-height: var(--workspace-topbar-height); - flex-shrink: 0; - align-items: center; - } - - /* Fade rows themselves as they pass beneath the top chrome. A mask remains - visible even when the header and timeline share the same background. */ - .chat-timeline-scroll-fade, - .settings-page-scroll-fade, - .pull-requests-scroll-fade { - --topbar-scroll-fade-height: 2.5rem; - -webkit-mask-image: - linear-gradient( - to bottom, - transparent 0%, - rgb(0 0 0 / 10%) 10%, - rgb(0 0 0 / 30%) 24%, - rgb(0 0 0 / 58%) 42%, - rgb(0 0 0 / 82%) 62%, - rgb(0 0 0 / 96%) 82%, - black 100% - ), - linear-gradient(black, black), linear-gradient(black, black); - -webkit-mask-position: top, bottom, right; - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), - var(--app-scrollbar-width) 100%; - mask-image: - linear-gradient( - to bottom, - transparent 0%, - rgb(0 0 0 / 10%) 10%, - rgb(0 0 0 / 30%) 24%, - rgb(0 0 0 / 58%) 42%, - rgb(0 0 0 / 82%) 62%, - rgb(0 0 0 / 96%) 82%, - black 100% - ), - linear-gradient(black, black), linear-gradient(black, black); - mask-position: top, bottom, right; - mask-repeat: no-repeat; - mask-size: - 100% var(--topbar-scroll-fade-height), - 100% calc(100% - var(--topbar-scroll-fade-height)), - var(--app-scrollbar-width) 100%; - } - - /* The pull request list sits directly under its topbar, so the tall band the chat and - settings pages fade under would read as empty padding here. A shorter band keeps the - fade while letting the controls start near the chrome. */ - .pull-requests-scroll-fade { - --topbar-scroll-fade-height: 1.5rem; - } - @keyframes settings-search-target-pulse { 0%, 100% { @@ -660,56 +721,29 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } - .settings-page-scroll-fade div.settings-search-target-pulse, - .settings-page-scroll-fade section.settings-search-target-pulse > div:first-child { + [data-settings-page-scroll] div.settings-search-target-pulse, + [data-settings-page-scroll] section.settings-search-target-pulse > div:first-child { animation: settings-search-target-pulse 650ms ease-in-out 2; border-radius: 0.75rem; } /* The pulse is the destination indicator; without it (reduced motion), the focus outline takes over, so exactly one indicator shows at a time. */ - .settings-page-scroll-fade .settings-search-target-pulse:focus { + [data-settings-page-scroll] .settings-search-target-pulse:focus { outline: none; } - .workspace-titlebar-controls { - position: absolute; - top: var(--workspace-controls-top); - right: var(--workspace-controls-right); - display: flex; - height: var(--workspace-topbar-height); - align-items: center; - -webkit-app-region: no-drag; - } - - .surface-subheader { - @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background; - } - - [data-preview-panel-mode="inline"] [data-right-panel-surface-content] [data-surface-subheader] { - height: calc(var(--spacing) * 7); - min-height: calc(var(--spacing) * 7); - margin-bottom: calc(var(--spacing) * 3); - border-bottom-color: transparent; - } - - .chat-composer-horizontal-inset { - padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem); - padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem); - } - - .chat-composer-glass { - background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - .chat-composer-glass-shell { --chat-composer-glass-surface: var(--card); --chat-composer-outline: rgb(0 0 0 / 8%); - position: relative; isolation: isolate; + + @variant dark { + --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); + --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent); + --chat-composer-highlight: rgb(255 255 255 / 3%); + } } .chat-composer-glass-shell::before { @@ -769,8 +803,15 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } .chat-composer-glass-host { - position: relative; box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); + + @variant dark { + box-shadow: none; + + &::after { + box-shadow: inset 0 1px var(--chat-composer-highlight); + } + } } .chat-composer-glass-host::after { @@ -800,6 +841,21 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .chat-composer-context-strip { position: relative; isolation: isolate; + + @variant dark { + &::before { + border-color: rgb(255 255 255 / 7%); + background: + linear-gradient( + to bottom, + transparent 0 1rem, + rgb(0 0 0 / 18%) 1rem, + transparent calc(1rem + 10px) + ), + rgb(255 255 255 / 2%); + box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); + } + } } .chat-composer-context-strip::before { @@ -815,33 +871,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil content: ""; } - .dark .chat-composer-glass-shell { - --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); - --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent); - --chat-composer-highlight: rgb(255 255 255 / 3%); - } - - .dark .chat-composer-glass-host { - box-shadow: none; - } - - .dark .chat-composer-glass-host::after { - box-shadow: inset 0 1px var(--chat-composer-highlight); - } - - .dark .chat-composer-context-strip::before { - border-color: rgb(255 255 255 / 7%); - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - rgb(255 255 255 / 2%); - box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); - } - @supports not (clip-path: shape(from 0 0, line to 1px 1px)) { .chat-composer-glass-shell-with-context::before { inset-block-end: var(--chat-composer-context-extension); @@ -859,106 +888,23 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); } - .dark .chat-composer-context-strip::before { - background: - linear-gradient( - to bottom, - transparent 0 1rem, - rgb(0 0 0 / 18%) 1rem, - transparent calc(1rem + 10px) - ), - linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), - color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); + .chat-composer-context-strip { + @variant dark { + &::before { + background: + linear-gradient( + to bottom, + transparent 0 1rem, + rgb(0 0 0 / 18%) 1rem, + transparent calc(1rem + 10px) + ), + linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)), + color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent); + } + } } } - .alert-glass { - --alert-glass-tint: transparent; - - background: - linear-gradient( - color-mix(in srgb, var(--alert-glass-tint) 4%, transparent), - color-mix(in srgb, var(--alert-glass-tint) 4%, transparent) - ), - color-mix(in srgb, var(--background) var(--glass-opacity), transparent) !important; - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - .alert-glass[data-variant="error"] { - --alert-glass-tint: var(--destructive); - } - - .alert-glass[data-variant="info"] { - --alert-glass-tint: var(--info); - } - - .alert-glass[data-variant="success"] { - --alert-glass-tint: var(--success); - } - - .alert-glass[data-variant="warning"] { - --alert-glass-tint: var(--warning); - } - - .dialog-glass { - background: color-mix(in srgb, var(--background) var(--glass-opacity), transparent); - -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); - } - - .dialog-backdrop { - background: color-mix(in srgb, var(--background) 60%, transparent); - -webkit-backdrop-filter: blur(4px); - backdrop-filter: blur(4px); - } - - .dropdown-glass { - /* - * Elevated glass needs a denser tint than broad ambient surfaces. Nesting - * the user-controlled mix inside an 18% popover tint preserves the full - * opacity setting range (40% -> 51%, 80% -> 84%, 100% -> 100%) while - * keeping high-contrast page content from blooming through menus. - */ - background: color-mix( - in srgb, - var(--popover) 18%, - color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) - ); - -webkit-backdrop-filter: blur(var(--glass-blur)); - backdrop-filter: blur(var(--glass-blur)); - border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); - box-shadow: 0 16px 40px -18px rgb(0 0 0 / 55%); - } - - .dialog-glass { - border-color: color-mix(in srgb, var(--foreground) 10%, transparent); - box-shadow: 0 24px 64px -24px rgb(0 0 0 / 65%); - } - - .dark .dropdown-glass { - box-shadow: 0 18px 44px -18px rgb(0 0 0 / 80%); - } - - .dark .model-picker-surface.model-picker-surface { - background: color-mix( - in srgb, - var(--popover) 18%, - color-mix(in srgb, var(--popover) var(--glass-opacity), transparent) - ); - } - - .dark .dialog-glass { - border-color: color-mix(in srgb, var(--color-white) 8%, transparent); - box-shadow: - inset 0 1px rgb(255 255 255 / 4%), - 0 24px 72px -20px rgb(0 0 0 / 90%); - } - - .dark .dialog-backdrop { - background: color-mix(in srgb, var(--background) 64%, transparent); - } - .settings-slider { --settings-slider-progress: 0%; --settings-slider-fill-offset: 0.5rem; @@ -1074,32 +1020,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } - @media (min-width: 40rem) { - .chat-timeline-scroll-fade, - .settings-page-scroll-fade { - --topbar-scroll-fade-height: 3rem; - } - - .chat-composer-horizontal-inset { - padding-inline-start: calc(env(safe-area-inset-left) + 1.25rem); - padding-inline-end: calc(env(safe-area-inset-right) + 1.25rem); - } - } - @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) { - .chat-composer-glass, - .alert-glass { - background: var(--background) !important; - } - .chat-composer-glass-shell::before { background: var(--chat-composer-glass-surface); } - - .dialog-glass, - .dropdown-glass { - background: var(--popover) !important; - } } } @@ -1205,15 +1129,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --terminal-foreground: var(--foreground); --terminal-cursor: rgb(38 56 78); --terminal-selection-background: rgb(37 63 99 / 20%); - --terminal-scrollbar: rgb(0 0 0 / 15%); - --terminal-scrollbar-hover: rgb(0 0 0 / 25%); @variant dark { color-scheme: dark; /* Keep the workspace in the same neutral-black family as sidebar v2. Surfaces lift from this base instead of starting from a milky gray. */ --background: var(--color-neutral-950); - --app-chrome-background: var(--background); --surface-raised: var(--secondary); --foreground: var(--color-neutral-100); --card: color-mix(in srgb, var(--background) 97%, var(--color-white)); @@ -1221,54 +1142,31 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --popover: color-mix(in srgb, var(--background) 94%, var(--color-white)); --popover-foreground: var(--color-neutral-100); --primary: oklch(0.571 0.21 264); - --primary-foreground: var(--color-white); --secondary: --alpha(var(--color-white) / 4%); --secondary-foreground: var(--color-neutral-100); --muted: --alpha(var(--color-white) / 4%); --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); - --placeholder: var(--muted-foreground); - --secondary-label: var(--muted-foreground); - --icon-muted: var(--muted-foreground); - --message-surface: var(--accent); - --message-foreground: var(--foreground); - --message-action: var(--primary); - --message-action-foreground: var(--primary-foreground); - --message-action-hover: color-mix(in srgb, var(--primary) 90%, var(--background)); --accent: --alpha(var(--color-white) / 4%); --accent-foreground: var(--color-neutral-100); --error: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); --error-foreground: var(--color-red-400); --error-surface: color-mix(in srgb, var(--error) 16%, transparent); - --destructive: var(--error); --border: --alpha(var(--color-white) / 6%); --input: --alpha(var(--color-white) / 8%); - --ring: var(--primary); - --destructive-foreground: var(--error-foreground); - --info: var(--color-blue-500); --info-foreground: var(--color-blue-400); - --success: var(--color-emerald-500); --success-foreground: var(--color-emerald-400); - --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-400); --warning-surface: color-mix(in srgb, var(--warning) 16%, transparent); - --update: var(--primary); --update-foreground: var(--color-blue-400); --update-surface: color-mix(in srgb, var(--update) 18%, transparent); --sidebar: var(--card); - --sidebar-foreground: var(--foreground); - --sidebar-muted-foreground: var(--muted-foreground); --sidebar-control-surface: var(--muted); --sidebar-row-hover: var(--accent); --sidebar-row-active: var(--accent); --sidebar-row-selected: var(--muted); - --sidebar-border: var(--border); --sidebar-stage-fade: var(--card); - --terminal-background: var(--background); - --terminal-foreground: var(--foreground); --terminal-cursor: rgb(180 203 255); --terminal-selection-background: rgb(180 203 255 / 25%); - --terminal-scrollbar: rgb(255 255 255 / 10%); - --terminal-scrollbar-hover: rgb(255 255 255 / 18%); } } @@ -1295,32 +1193,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --sidebar-row-selected: var(--color-white); --sidebar-border: var(--color-zinc-200); --sidebar-stage-fade: var(--sidebar); - background-color: var(--sidebar); -} - -.dark [data-app-sidebar] { - --background: #000; - --foreground: #f1f3f7; - --card: #000; - --card-foreground: var(--foreground); - --accent: #191a1d; - --accent-foreground: #f7f9ff; - --muted: #0a0a0a; - --muted-foreground: #a3a3a3; - --border: rgb(255 255 255 / 8%); - --input: rgb(255 255 255 / 18%); - --sidebar: var(--card); - --sidebar-foreground: var(--foreground); - --sidebar-muted-foreground: var(--muted-foreground); - --sidebar-control-surface: var(--muted); - --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); - --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); - --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); - --sidebar-border: var(--border); - /* The stage-channel header art must ramp to THIS panel's surface, not the - global chrome background, or the fade shows a seam (same rule as the - light palette above). */ - --sidebar-stage-fade: var(--card); + + @variant dark { + --background: #000; + --foreground: #f1f3f7; + --card: #000; + --card-foreground: var(--foreground); + --accent: #191a1d; + --accent-foreground: #f7f9ff; + --muted: #0a0a0a; + --muted-foreground: #a3a3a3; + --border: rgb(255 255 255 / 8%); + --input: rgb(255 255 255 / 18%); + --sidebar: var(--card); + --sidebar-foreground: var(--foreground); + --sidebar-muted-foreground: var(--muted-foreground); + --sidebar-control-surface: var(--muted); + --sidebar-row-hover: color-mix(in srgb, var(--foreground) 8%, transparent); + --sidebar-row-active: color-mix(in srgb, var(--foreground) 11%, transparent); + --sidebar-row-selected: color-mix(in srgb, var(--foreground) 7%, transparent); + --sidebar-border: var(--border); + --sidebar-stage-fade: var(--card); + } } /* Theme files are expressed in app color roles and mapped to the existing @@ -1328,8 +1222,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compatibility overrides so both navigation implementations receive the same palette. Success, info, provider, and channel identity colors remain independent; error, warning, and update roles are themeable below. */ -html[data-theme-id], -html.dark[data-theme-id] { +html[data-theme-id] { --background: var(--app-theme-canvas); --app-chrome-background: var(--app-theme-chrome); --toolbar-background: var(--app-theme-toolbar); @@ -1393,8 +1286,6 @@ html.dark[data-theme-id] { --terminal-foreground: var(--app-theme-terminal-foreground); --terminal-cursor: var(--app-theme-terminal-cursor); --terminal-selection-background: var(--app-theme-terminal-selection-background); - --terminal-scrollbar: var(--app-theme-terminal-scrollbar); - --terminal-scrollbar-hover: var(--app-theme-terminal-scrollbar-hover); } /* T3 Chat's composer is a translucent lift over --chat-background. Route its @@ -1402,35 +1293,21 @@ html.dark[data-theme-id] { another tint from the canvas, which made the dark composer too red. */ html[data-theme-id] .chat-composer-glass-shell { --chat-composer-glass-surface: var(--app-theme-surface-raised); -} - -html[data-theme-id]:not(.dark) .chat-composer-glass-shell { --chat-composer-outline: var(--app-theme-toolbar-border); -} -html.dark[data-theme-id] .chat-composer-glass-shell { - --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background)); - --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent); + @variant dark { + --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background)); + --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent); + } } -html.dark[data-theme-id="t3-chat"] .chat-composer-glass-shell { +html[data-theme-id="t3-chat"] .chat-composer-glass-shell { /* T3 Chat's visible composer edge is a dark plum, not the stock translucent white outline. Its highlight is derived from --chat-input-gradient. */ - --chat-composer-outline: #241e28; - --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent); -} - -html[data-theme-id]:not(.dark) { - color-scheme: light; -} - -html.dark[data-theme-id] { - color-scheme: dark; -} - -html[data-theme-id] body { - background-color: var(--app-chrome-background); - color: var(--foreground); + @variant dark { + --chat-composer-outline: #241e28; + --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent); + } } /* Theme-token dependency probes are restored synchronously, before paint. Keep @@ -1530,8 +1407,8 @@ html[data-theme-id] [data-chat-header] [data-toolbar-control] { toggle's when the trigger renders the toggle, so match both. */ html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"], html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"], -html[data-theme-id] .workspace-titlebar-controls [data-slot="toggle"], -html[data-theme-id] .workspace-titlebar-controls [data-slot="tooltip-trigger"] { +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"], +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"] { --control-icon-color: var(--toolbar-foreground); color: var(--toolbar-foreground); } @@ -1575,19 +1452,23 @@ html[data-theme-id] .chat-markdown .chat-markdown-chrome-action { /* T3 Chat renders inline code and compact chat artifacts with its translucent secondary surface flattened over the light chat canvas. The raw muted and secondary tokens are substantially darker than those visible pixels. */ -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state], -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-header] { - background-color: var(--message-surface); -} +html[data-theme-id="t3-chat"] { + @variant light { + & .chat-markdown :not(pre) > code, + & [data-changed-files-state], + & [data-changed-files-header] { + background-color: var(--message-surface); + } -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code, -html[data-theme-id="t3-chat"]:not(.dark) [data-changed-files-state] { - border-color: transparent; -} + & .chat-markdown :not(pre) > code, + & [data-changed-files-state] { + border-color: transparent; + } -html[data-theme-id="t3-chat"]:not(.dark) .chat-markdown :not(pre) > code { - color: var(--message-foreground); + & .chat-markdown :not(pre) > code { + color: var(--message-foreground); + } + } } html[data-theme-id] .chat-markdown .chat-markdown-chrome-action:hover, @@ -1615,40 +1496,19 @@ html[data-theme-id] [data-app-sidebar] { --sidebar-row-selected: var(--app-theme-sidebar-row-selected); --sidebar-border: var(--app-theme-sidebar-border); --sidebar-stage-fade: var(--app-theme-sidebar); - background-color: var(--sidebar); -} - -/* Keep the navigation edge as quiet as the standard palettes. Theme files may - still use sidebarBorder for controls and internal separators, but the outer - divider should not become more prominent just because a palette is vivid. */ -html[data-theme-id] [data-app-sidebar] { border-color: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent); -} -html.dark[data-theme-id] [data-app-sidebar] { - border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + @variant dark { + border-color: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); + } } /* T3 Chat's panel divider is deliberately pink, and its resize affordance keeps that color while hovered. Do not neutralize this branded edge. */ -html.dark[data-theme-id="t3-chat"] [data-app-sidebar] { - border-color: var(--sidebar-border); -} - -.theme-json-key { - color: var(--app-theme-accent, var(--color-blue-600)); -} - -.theme-json-string { - color: var(--app-theme-message-action, var(--color-emerald-600)); -} - -.theme-json-number { - color: var(--app-theme-secondary-foreground, var(--color-amber-600)); -} - -.theme-json-constant { - color: var(--app-theme-accent-surface-foreground, var(--color-violet-600)); +html[data-theme-id="t3-chat"] [data-app-sidebar] { + @variant dark { + border-color: var(--sidebar-border); + } } body { @@ -1768,125 +1628,7 @@ code { background: var(--app-scrollbar-thumb-hover); } -/* Settings -> Appearance can point the composer at its own face (for example a - mono font); default follows the sans stack. Applied on the surface wrapper so - the editor and its placeholder inherit together. */ -.composer-editor-surface { - font-family: var(--font-composer, var(--font-sans)); - font-size: var(--font-size-prompt, 0.875rem); -} - -/* Touch browsers zoom the page when a focused field is under 16px, so keep - the floor there regardless of the preference. Gated on a coarse pointer: - the zoom quirk does not exist on desktop, where a narrow window must not - silently override a smaller chosen prompt size. */ -@media (max-width: 39.999rem) and (pointer: coarse) { - .composer-editor-surface { - font-size: max(var(--font-size-prompt, 1rem), 16px); - } -} - -.t3-ghostty-canvas { - cursor: text; -} - -.t3-ghostty-scrollbar { - position: absolute; - z-index: 1; - top: 4px; - right: 1px; - bottom: 4px; - width: var(--app-scrollbar-width); - cursor: default; - touch-action: none; -} - -.t3-ghostty-scrollbar-thumb { - position: absolute; - top: 0; - right: 1px; - left: 1px; - border-radius: 3px; - background: var(--app-scrollbar-thumb); - transition: background-color 120ms ease-out; -} - -.t3-ghostty-scrollbar:hover .t3-ghostty-scrollbar-thumb, -.t3-ghostty-scrollbar:focus-visible .t3-ghostty-scrollbar-thumb { - background: var(--app-scrollbar-thumb-hover); -} - -.model-picker-list::-webkit-scrollbar-track { - margin-block: 0.5rem; -} - -.model-picker-list-scroll-fade-top, -.model-picker-list-scroll-fade-bottom { - -webkit-mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); - -webkit-mask-position: left, right; - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: - calc(100% - var(--app-scrollbar-width)) 100%, - var(--app-scrollbar-width) 100%; - mask-image: var(--model-picker-list-scroll-mask), linear-gradient(black, black); - mask-position: left, right; - mask-repeat: no-repeat; - mask-size: - calc(100% - var(--app-scrollbar-width)) 100%, - var(--app-scrollbar-width) 100%; -} - -.model-picker-list-scroll-fade-top { - --model-picker-list-scroll-mask: linear-gradient(to bottom, transparent, black var(--fade-size)); -} - -.model-picker-list-scroll-fade-bottom { - --model-picker-list-scroll-mask: linear-gradient( - to bottom, - black calc(100% - var(--fade-size)), - transparent - ); -} - -.model-picker-list-scroll-fade-top.model-picker-list-scroll-fade-bottom { - --model-picker-list-scroll-mask: linear-gradient( - to bottom, - transparent, - black var(--fade-size), - black calc(100% - var(--fade-size)), - transparent - ); -} - -.turn-chip-strip { - scrollbar-width: none; - -ms-overflow-style: none; - overscroll-behavior-x: contain; -} - -.turn-chip-strip::-webkit-scrollbar { - display: none; -} - -/* Reasoning select -- clickable label surface */ -label:has(> select#reasoning-effort) { - position: relative; -} -label:has(> select#reasoning-effort) select { - position: absolute; - inset: 0; - opacity: 0; - cursor: pointer; - width: 100%; - height: 100%; -} - /* Chat markdown rendering */ -.chat-markdown { - min-width: 0; - overflow-wrap: anywhere; - word-break: break-word; -} .chat-markdown > :first-child { margin-top: 0; @@ -1998,18 +1740,6 @@ label:has(> select#reasoning-effort) select { background-size: 4px 2px; } -.chat-markdown .chat-markdown-link-favicon { - @apply inline-flex; - width: 14px; - height: 14px; - margin-inline: 0.25em 0.2em; - vertical-align: -0.125em; -} - -.chat-markdown .chat-markdown-link-leading { - white-space: nowrap; -} - .chat-markdown blockquote { border-left: 2px solid var(--border); padding-left: 0.8rem; @@ -2054,11 +1784,7 @@ label:has(> select#reasoning-effort) select { font-size: 0.75rem; } -.chat-markdown a.chat-markdown-file-link { - color: var(--foreground); - text-decoration: none; -} - +.chat-markdown a.chat-markdown-file-link, .chat-markdown a.chat-markdown-file-link:hover { color: var(--foreground); text-decoration: none; @@ -2076,75 +1802,26 @@ label:has(> select#reasoning-effort) select { border-radius: 0.75rem; background: var(--muted); padding: 0.8rem 0.9rem; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; } .chat-markdown pre code { border: none; background: transparent; padding: 0; - font-size: 0.75rem; -} - -.chat-markdown pre { - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; } .chat-markdown pre::-webkit-scrollbar { height: 7px; } -.chat-markdown pre::-webkit-scrollbar-track { - background: transparent; -} - .chat-markdown pre::-webkit-scrollbar-thumb { border-radius: 999px; background: color-mix(in srgb, var(--border) 78%, transparent); } -.markdown-file-link-tooltip-scroll { - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--border) 78%, transparent) transparent; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar { - height: 6px; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar-track { - background: transparent; -} - -.markdown-file-link-tooltip-scroll::-webkit-scrollbar-thumb { - border-radius: 999px; - background: color-mix(in srgb, var(--border) 78%, transparent); -} - -.chat-markdown .chat-markdown-codeblock { - margin: 0.65rem 0; - overflow: hidden; - border-radius: var(--radius); -} - -.chat-markdown .chat-markdown-codeblock-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.375rem 0.375rem 0 0.75rem; - color: color-mix(in srgb, var(--foreground) 72%, transparent); -} - -.chat-markdown .chat-markdown-codeblock-title { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 0.4rem; - font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); - font-size: 0.6875rem; -} - +.chat-markdown .chat-markdown-codeblock-header, .chat-markdown .chat-markdown-chrome-action { color: color-mix(in srgb, var(--foreground) 72%, transparent); } @@ -2220,13 +1897,6 @@ label:has(> select#reasoning-effort) select { overflow-wrap: anywhere; } -.chat-markdown .chat-markdown-table-footer { - display: flex; - align-items: center; - justify-content: space-between; - margin-top: 0.125rem; -} - /* Prompt-stash save acknowledgement: the new count fades up from just below its resting position, once, then stops. One-shot and event-driven (React remounts the element by key on each stash) — no continuous animation. */ @@ -2241,19 +1911,6 @@ label:has(> select#reasoning-effort) select { } } -.prompt-stash-count-enter { - animation: prompt-stash-count-enter 180ms ease-out both; -} - -@media (prefers-reduced-motion: reduce) { - .prompt-stash-count-enter { - animation: none; - } - [data-slot="skeleton"]::after { - content: none; - } -} - @keyframes provider-update-pill-countdown { from { transform: scaleX(1); @@ -2263,23 +1920,6 @@ label:has(> select#reasoning-effort) select { } } -.provider-update-pill-progress { - animation: provider-update-pill-countdown var(--provider-update-pill-dismiss-ms) linear forwards; -} - -/* Diffs theme bridge (match diff surfaces to app palette) */ -.diff-panel-viewport { - background: var(--background); -} - -/* Diffs live directly on the panel canvas. Normal chat code blocks may use a - raised code surface, but carrying that fill into the diff creates a card-like - rectangle that does not belong in the panel. */ -.diff-render-surface { - --code-background: var(--background); -} - -.diff-render-file, .diff-render-surface diffs-container { border: 0; border-radius: 0; @@ -2360,40 +2000,3 @@ label:has(> select#reasoning-effort) select { .ultrathink-chroma { animation: ultrathink-chroma-shift 10s linear infinite; } - -.ultrathink-pill { - background: - linear-gradient(var(--card), var(--card)) padding-box, - var(--ultrathink-spectrum) border-box; - background-size: - 100% 100%, - 220% 220%; - background-position: - 0 0, - 0% 50%; - animation: ultrathink-rainbow 10s linear infinite; - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--card) 82%, transparent); -} - -.ultrathink-word { - display: inline-block; - color: transparent; - background-image: var(--ultrathink-spectrum); - background-size: 220% 220%; - background-position: 0% 50%; - background-clip: text; - -webkit-background-clip: text; - animation: ultrathink-rainbow 10s linear infinite; -} - -/* Composer chips are non-editable decorators, so the browser skips them when - painting text selection; this overlay stands in for the native highlight. */ -.composer-inline-chip[data-composer-chip-selected]::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 6px; - background-color: Highlight; - opacity: 0.3; - pointer-events: none; -} diff --git a/apps/web/src/routes/-chatIndexTitlebar.test.ts b/apps/web/src/routes/-chatIndexTitlebar.test.ts index 5e74103a5..803ba7871 100644 --- a/apps/web/src/routes/-chatIndexTitlebar.test.ts +++ b/apps/web/src/routes/-chatIndexTitlebar.test.ts @@ -1,4 +1,5 @@ -// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the onboarding header with the shared titlebar contract. +// @effect-diagnostics nodeBuiltinImport:off +// Regression coverage compares the onboarding header with the shared titlebar contract. import * as NodeFS from "node:fs"; import { describe, expect, it } from "vite-plus/test"; @@ -14,7 +15,9 @@ describe("hosted static onboarding header", () => { const onboardingHeader = routeSource.slice(onboardingStart, onboardingEnd); - expect(onboardingHeader).toContain("workspace-topbar"); + expect(onboardingHeader).toContain("h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("min-h-[var(--workspace-topbar-height)]"); + expect(onboardingHeader).toContain("COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS"); expect(onboardingHeader).not.toMatch(/(?:^|\s)(?:[\w-]+:)*py-/); }); }); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index f616ac0f2..232217364 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -145,7 +145,7 @@ function HostedStaticOnboardingState() {
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index ab8663b35..bef24631e 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1308,7 +1308,8 @@ function PullRequestsRouteView() { // anchor the thread view's controls and the sidebar trigger use, so // every titlebar cluster in the app sits one shared inset from its // edge. - className="workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]" + className="absolute top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 mr-px flex h-[var(--workspace-topbar-height)] items-center gap-1 [-webkit-app-region:no-drag]" + data-workspace-titlebar-controls > {panelToggleControls}
@@ -1828,7 +1829,7 @@ function PullRequestsColumn({
{/* The top padding is the fade band's own height (1.5rem here), the same pairing the settings page makes: at rest the controls sit fully below the mask, and only diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index f14793ba5..a4b248c84 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -75,7 +75,7 @@ function SettingsContentLayout() { {!isElectron && (
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 0fc50de5e..d4ae94a3e 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -578,8 +578,7 @@ export class GhosttyTerminalSurface { options: GhosttyTerminalSurfaceOptions, ): Promise { const canvas = document.createElement("canvas"); - canvas.className = "t3-ghostty-canvas"; - canvas.style.cssText = "display:block;width:100%;height:100%;"; + canvas.className = "block size-full cursor-text"; canvas.setAttribute("aria-hidden", "true"); const input = document.createElement("textarea"); @@ -592,14 +591,16 @@ export class GhosttyTerminalSurface { "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;"; const scrollbar = document.createElement("div"); - scrollbar.className = "t3-ghostty-scrollbar"; + scrollbar.className = + "group absolute top-1 right-px bottom-1 z-1 w-[var(--app-scrollbar-width)] cursor-default touch-none"; scrollbar.setAttribute("role", "scrollbar"); scrollbar.setAttribute("aria-label", "Terminal scrollback"); scrollbar.setAttribute("aria-orientation", "vertical"); scrollbar.tabIndex = 0; scrollbar.hidden = true; const scrollbarThumb = document.createElement("div"); - scrollbarThumb.className = "t3-ghostty-scrollbar-thumb"; + scrollbarThumb.className = + "absolute inset-x-px top-0 rounded-[3px] bg-[var(--app-scrollbar-thumb)] transition-[background-color] duration-[120ms] ease-[ease-out] group-hover:bg-[var(--app-scrollbar-thumb-hover)] group-focus-visible:bg-[var(--app-scrollbar-thumb-hover)]"; scrollbar.append(scrollbarThumb); mount.replaceChildren(canvas, input, scrollbar); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8aeccb230..b54f73c70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ patchedDependencies: '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 - '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 + '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 @@ -467,7 +467,7 @@ importers: version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@ff-labs/fff-node': specifier: 0.9.4 - version: 0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368) + version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) '@opencode-ai/sdk': specifier: ^1.3.15 version: 1.15.13 @@ -477,9 +477,6 @@ importers: effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) - msgpackr-extract: - specifier: 3.0.4 - version: 3.0.4 node-pty: specifier: ^1.1.0 version: 1.1.0 @@ -929,9 +926,6 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(bufferutil@4.1.0)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@electron/asar': - specifier: ^3.4.1 - version: 3.4.1 '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -13128,7 +13122,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.9.4': optional: true - '@ff-labs/fff-node@0.9.4(patch_hash=ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368)': + '@ff-labs/fff-node@0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8)': dependencies: ffi-rs: 1.3.2 optionalDependencies: @@ -19658,6 +19652,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true msgpackr@2.0.4: optionalDependencies: @@ -19753,6 +19748,7 @@ snapshots: node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 + optional: true node-gyp-build@4.8.4: optional: true From ab8fefa838ddcccc4e246f7bf004a9f204b9f1c3 Mon Sep 17 00:00:00 2001 From: Simone Date: Fri, 14 Aug 2026 23:56:12 +0200 Subject: [PATCH 04/99] fix(server): handle files named HEAD in git status (#6397) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> (cherry picked from commit f0719072a1c6435b5a91243afc57bc8bf1f3e2b6) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 21 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 9 +++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 18e594512..fc1b4d127 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -950,6 +950,27 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports changes to a file named HEAD", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "HEAD", "first line\n"); + yield* git(cwd, ["add", "HEAD"]); + yield* git(cwd, ["commit", "-m", "add HEAD file"]); + yield* writeTextFile(cwd, "HEAD", "first line\nsecond line\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.hasWorkingTreeChanges, true); + assert.deepInclude(status.workingTree.files, { + path: "HEAD", + insertions: 1, + deletions: 0, + }); + }), + ); + it.effect("reports default-branch delta separately from upstream delta", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 1489db9b3..916286537 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -420,9 +420,10 @@ function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository"); } function isUnbornHeadStderr(stderr: string): boolean { + const normalized = stderr.toLowerCase(); return ( - stderr.toLowerCase().includes("unknown revision") && - stderr.toLowerCase().includes("path not in the working tree") + normalized.includes("bad revision 'head'") || + (normalized.includes("unknown revision") && normalized.includes("path not in the working tree")) ); } @@ -1600,7 +1601,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", cwd, - ["diff", "HEAD", "--numstat"], + ["diff", "HEAD", "--numstat", "--"], { allowNonZeroExit: true }, ).pipe( Effect.flatMap((result) => { @@ -1642,7 +1643,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...gitCommandContext({ operation: "GitVcsDriver.statusDetails.numstat", cwd, - args: ["diff", "HEAD", "--numstat"], + args: ["diff", "HEAD", "--numstat", "--"], }), detail: "git diff HEAD --numstat failed.", exitCode: result.exitCode, From 68aa8313cfda4876bdbdca189d1c74deedcedf20 Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 15 Aug 2026 01:32:33 +0200 Subject: [PATCH 05/99] fix(web): bound OKLCH gamut mapping (#6485) Co-authored-by: Simone <185146821+Lucenx9@users.noreply.github.com> (cherry picked from commit 74f7b434865c2d758c7b1cd5f52f4c96b76d03fb) --- apps/web/src/themePalette.test.ts | 12 ++++++++++++ apps/web/src/themePalette.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 156f4c714..402c71962 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -246,6 +246,18 @@ describe("theme files", () => { } }); + it("gamut maps extreme finite OKLCH chroma from theme files", () => { + const theme = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Extreme chroma", + appearance: "light", + colors: { accent: "oklch(0.5 1e303 0)" }, + }); + + expect(theme.colors.accent).toBe("oklch(0.5 1e+303 0)"); + expect(themeColorToHex(theme.colors.accent)).toBe("#b5005e"); + }); + it("rejects unknown roles and invalid color values", () => { expect(() => parseThemeFile({ diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 14b814f8b..bca54cef9 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -902,7 +902,11 @@ function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch { let low = 0; let high = color.C; - const steps = Math.max(1, Math.ceil(Math.log2(Math.max(color.C, 0.000001) / 0.000001))); + const chromaResolution = 0.000001; + const steps = Math.max( + 1, + Math.ceil(Math.log2(Math.max(color.C, chromaResolution)) - Math.log2(chromaResolution)), + ); for (let step = 0; step < steps; step += 1) { const mid = (low + high) / 2; if (isInGamut(mid)) low = mid; From 8de01a3b8c3d37120c9dc5cf1e0b523c2c46a617 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 14 Aug 2026 20:35:34 -0400 Subject: [PATCH 06/99] feat(web): open remote environments in your local editor over SSH (#6572) Co-authored-by: Claude Fable 5 (cherry picked from commit 57a299a7852b430613dfdd97ba76249ee9f374d5) --- apps/desktop/src/electron/ElectronShell.ts | 12 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/window.ts | 28 +++ apps/desktop/src/preload.ts | 1 + .../desktop/src/wsl/DesktopWslBackend.test.ts | 1 + .../src/environment/RemoteOpenTargets.test.ts | 126 ++++++++++++ .../src/environment/RemoteOpenTargets.ts | 72 +++++++ apps/server/src/preview/PortScanner.test.ts | 3 + apps/server/src/server.test.ts | 14 +- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 7 + .../src/components/chat/ChatHeader.test.ts | 21 +- apps/web/src/components/chat/ChatHeader.tsx | 16 +- apps/web/src/components/chat/OpenInPicker.tsx | 100 ++++++--- .../src/components/files/FilePreviewPanel.tsx | 5 +- apps/web/src/remoteOpen.test.ts | 149 ++++++++++++++ apps/web/src/remoteOpen.ts | 189 ++++++++++++++++++ packages/contracts/src/editor.ts | 79 +++++++- packages/contracts/src/ipc.ts | 7 + packages/contracts/src/server.ts | 8 +- packages/shared/src/Net.ts | 7 + packages/ssh/src/tunnel.test.ts | 1 + scripts/dev-runner.test.ts | 1 + 24 files changed, 806 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/environment/RemoteOpenTargets.test.ts create mode 100644 apps/server/src/environment/RemoteOpenTargets.ts create mode 100644 apps/web/src/remoteOpen.test.ts create mode 100644 apps/web/src/remoteOpen.ts diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 316d3138b..126be71b6 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,3 +1,4 @@ +import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -5,7 +6,16 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:"]); +// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) +// must reach the OS handler; every other non-web scheme stays blocked. +const SAFE_EXTERNAL_PROTOCOLS = new Set([ + "http:", + "https:", + ...REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { + const scheme = remoteSchemeForEditor(id); + return scheme === undefined ? [] : [`${scheme}:`]; + }), +]); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index cb35ad19a..3d9ff022c 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -36,6 +36,7 @@ import { getLocalEnvironmentBearerToken, getWindowFullscreenState, openExternal, + probeRemoteEditors, pickFolder, pickThemeFiles, setTheme, @@ -83,6 +84,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4a1213e4e..0e31082af 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -3,6 +3,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 7a39eb429..16f7a4694 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -3,12 +3,16 @@ import { DesktopAppBrandingSchema, DesktopEnvironmentBootstrapSchema, DesktopThemeSchema, + EDITORS, + EditorId, PickedThemeFileSchema, PickFolderOptionsSchema, PRIMARY_LOCAL_ENVIRONMENT_ID, + REMOTE_CAPABLE_EDITOR_IDS, type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -261,6 +265,30 @@ export const openExternal = DesktopIpc.makeIpcMethod({ }), }); +export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, + payload: Schema.Undefined, + result: Schema.Array(EditorId), + // Probes THIS machine (where the renderer runs) for remote-capable editor + // CLIs, unlike the server's probe which walks the environment host's PATH. + // A Finder-launched app can miss PATH entries; an empty result makes the + // renderer fall back to VS Code only, so that fails soft. + handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { + const available: Array = []; + for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { + const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; + if (!commands) continue; + for (const command of commands) { + if (yield* isCommandAvailable(command, { env: process.env })) { + available.push(editorId); + break; + } + } + } + return available; + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2aa345ee5..61e345b90 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -105,6 +105,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ...(position === undefined ? {} : { position }), }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), + probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 2f58c6adc..ed8911d40 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -77,6 +77,7 @@ const backendConfigurationLayer = Layer.succeed( const netLayer = Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(41773), findAvailablePort: (preferred) => Effect.succeed(preferred), } satisfies NetService.NetService["Service"]); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts new file mode 100644 index 000000000..2f876b995 --- /dev/null +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -0,0 +1,126 @@ +import { it } from "@effect/vitest"; +import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +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 * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { describe, expect } from "vite-plus/test"; + +import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; + +const encoder = new TextEncoder(); + +const TAILSCALE_STATUS_JSON = JSON.stringify({ + Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, +}); + +/** Spawner whose `tailscale status --json` exits with the given output. */ +const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) => + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(input.stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ), + ), + ); + +const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => + Layer.succeed(NetService.NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: (_port, host) => Effect.succeed(host === "::1" ? input.ipv6 : input.ipv4), + reserveLoopbackPort: () => Effect.succeed(40_000), + findAvailablePort: (preferred) => Effect.succeed(preferred), + }); + +const resolveTargets = (input: { + readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; + readonly tailscale: { readonly exitCode: number; readonly stdout: string }; + readonly hostname: string; +}) => + Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( + Effect.provideService(HostProcessHostname, input.hostname), + Effect.provide( + RemoteOpenTargets.layer.pipe( + Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), + ), + ), + ); + +const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; +const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; + +describe("RemoteOpenTargets", () => { + it.effect("advertises nothing when no sshd accepts on either loopback", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + }); + expect(targets).toEqual([]); + }), + ); + + it.effect("orders the tailnet name before the mDNS name", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: true }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + }); + expect(targets).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, + { kind: "mdns", host: "bb-1.local" }, + ]); + }), + ); + + it.effect("accepts an sshd bound to IPv6 loopback only", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: true }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); + + it.effect("falls back to mDNS alone when tailscale is unavailable", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: false }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); + + it.effect("shortens an FQDN hostname to its first label for mDNS", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: true }, + tailscale: TAILSCALE_DOWN, + hostname: "bb-1.example.com", + }); + expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); + }), + ); +}); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts new file mode 100644 index 000000000..f70dfa68a --- /dev/null +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -0,0 +1,72 @@ +/** + * RemoteOpenTargets - resolves the SSH hostnames this environment advertises + * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). + * + * The server can only check itself: sshd listening locally, tailscaled + * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a + * given name resolves from the viewer's machine is inherently client-side. + * Targets are ordered most-reachable first (tailnet name works from anywhere + * on the tailnet; `.local` only on the same LAN). + */ +import { type RemoteOpenTarget } from "@t3tools/contracts"; +import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import { readTailscaleStatus } from "@t3tools/tailscale"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +const SSH_PORT = 22; + +export class RemoteOpenTargets extends Context.Service< + RemoteOpenTargets, + { + readonly resolveTargets: () => Effect.Effect>; + } +>()("t3/environment/RemoteOpenTargets") {} + +export const make = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const net = yield* NetService.NetService; + + const resolveTargets = Effect.gen(function* () { + // No local sshd means no name can work; advertise nothing so clients + // render a clear "no SSH route" state instead of links that hang. + // Check both loopback families: sshd can be bound IPv6-only. + const sshdListening = yield* Effect.zipWith( + net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), + net.hasListenerOnHost(SSH_PORT, "::1"), + (ipv4, ipv6) => ipv4 || ipv6, + ); + if (!sshdListening) { + return []; + } + + const targets: Array = []; + + // Tailscale absent or down is the common case, not an error. + const magicDnsName = yield* readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + if (magicDnsName !== null) { + targets.push({ kind: "tailscale", host: magicDnsName }); + } + + // os.hostname() may already be an FQDN (macOS often reports + // "Name.local"); mDNS names are always `.local`. + const hostname = yield* HostProcessHostname; + const shortHostname = hostname.split(".")[0]?.trim(); + if (shortHostname !== undefined && shortHostname.length > 0) { + targets.push({ kind: "mdns", host: `${shortHostname}.local` }); + } + + return targets; + }); + + return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); +}); + +export const layer = Layer.effect(RemoteOpenTargets, make); diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 944cbd85a..7fa15defe 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -47,6 +47,7 @@ let integrationListeningPort: number | null = null; const TestIntegrationNet = Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort), + hasListenerOnHost: (port) => Effect.sync(() => port === integrationListeningPort), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }); @@ -62,6 +63,7 @@ const makeProbeFailureLayer = ( Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), @@ -107,6 +109,7 @@ const makeLsofScannerLayer = (input: { Layer.succeed(Net.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(40_000), findAvailablePort: (preferred) => Effect.succeed(preferred), }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 754bb59de..ec7890994 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -107,6 +107,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import { OrchestrationListenerCallbackError } from "./orchestration/Errors.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -663,10 +664,15 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ExternalLauncher.ExternalLauncher)({ - resolveAvailableEditors: () => Effect.succeed([]), - ...options?.layers?.externalLauncher, - }), + Layer.mergeAll( + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + ...options?.layers?.externalLauncher, + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + ), ), Layer.provide( Layer.mock(ProcessDiagnostics.ProcessDiagnostics)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6e4e6cb70..253a9f6e8 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -81,6 +81,7 @@ import * as SourceControlRepositoryService from "./sourceControl/SourceControlRe import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -420,6 +421,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(TraceDiagnostics.layer), Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), + Layer.provideMerge(RemoteOpenTargets.layer), Layer.provideMerge(ServerLifecycleEvents.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9bba71953..4d3dfc0e5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -119,6 +119,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; @@ -381,6 +382,7 @@ const makeWsRpcLayer = ( const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; + const remoteOpenTargets = yield* RemoteOpenTargets.RemoteOpenTargets; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; @@ -1033,6 +1035,11 @@ const makeWsRpcLayer = ( availableEditors: yield* resolveAvailableEditorsForConfig( externalLauncher.resolveAvailableEditors(), ), + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/apps/web/src/components/chat/ChatHeader.test.ts b/apps/web/src/components/chat/ChatHeader.test.ts index 94fe070ee..a200a2069 100644 --- a/apps/web/src/components/chat/ChatHeader.test.ts +++ b/apps/web/src/components/chat/ChatHeader.test.ts @@ -12,26 +12,40 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: "codething-mvp", activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + remoteOpenMode: "local-exec", }), ).toBe(true); }); - it("hides the picker when hosted static mode has no primary environment", () => { + it("shows the picker for remote environments in deep-link mode", () => { + expect( + shouldShowOpenInPicker({ + activeProjectName: "codething-mvp", + activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), + primaryEnvironmentId, + remoteOpenMode: "remote-links", + }), + ).toBe(true); + }); + + it("shows the picker's unavailable state for remote environments without an SSH route", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId: null, + remoteOpenMode: "remote-unavailable", }), - ).toBe(false); + ).toBe(true); }); - it("hides the picker for remote environments", () => { + it("hides the picker for non-primary local backends", () => { expect( shouldShowOpenInPicker({ activeProjectName: "codething-mvp", activeThreadEnvironmentId: EnvironmentId.make("environment-remote"), primaryEnvironmentId, + remoteOpenMode: "local-exec", }), ).toBe(false); }); @@ -42,6 +56,7 @@ describe("shouldShowOpenInPicker", () => { activeProjectName: undefined, activeThreadEnvironmentId: primaryEnvironmentId, primaryEnvironmentId, + remoteOpenMode: "remote-links", }), ).toBe(false); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 643cf95ee..08e0422dd 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -30,6 +30,7 @@ import ProjectScriptsControl, { type ProjectScriptActionResult, } from "../ProjectScriptsControl"; import { OpenInPicker } from "./OpenInPicker"; +import { useRemoteOpenState, type RemoteOpenMode } from "../../remoteOpen"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useT3ProjectFileScripts } from "~/hooks/useT3ProjectFileScripts"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; @@ -91,12 +92,19 @@ export function shouldShowOpenInPicker(input: { readonly activeProjectName: string | undefined; readonly activeThreadEnvironmentId: EnvironmentId; readonly primaryEnvironmentId: EnvironmentId | null; + readonly remoteOpenMode: RemoteOpenMode; }): boolean { - return ( - Boolean(input.activeProjectName) && + if (!input.activeProjectName) return false; + if ( input.primaryEnvironmentId !== null && input.activeThreadEnvironmentId === input.primaryEnvironmentId - ); + ) { + return true; + } + // Remote environments get the picker in deep-link mode (or its explicit + // "no SSH route" state). Non-primary local backends (e.g. WSL) keep it + // hidden, matching pre-remote behavior. + return input.remoteOpenMode !== "local-exec"; } export const ChatHeader = memo(function ChatHeader({ @@ -128,10 +136,12 @@ export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeProjectScripts ? activeProjectCwd : null, ); + const remoteOpenState = useRemoteOpenState(activeThreadEnvironmentId); const showOpenInPicker = shouldShowOpenInPicker({ activeProjectName, activeThreadEnvironmentId, primaryEnvironmentId, + remoteOpenMode: remoteOpenState.mode, }); const activeThreadRef = useMemo( () => scopeThreadRef(activeThreadEnvironmentId, activeThreadId), diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 8b7a96880..afe35e185 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,7 +1,19 @@ -import { EditorId, type EnvironmentId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { + buildRemoteOpenUrl, + EditorId, + type EnvironmentId, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; import { usePreferredEditor } from "../../editorPreferences"; +import { + openRemoteEditorUrl, + useRemoteCapableEditors, + useRemoteOpenHint, + useRemoteOpenState, +} from "../../remoteOpen"; +import { useEnvironment } from "../../state/environments"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; @@ -199,10 +211,17 @@ export const OpenInPicker = memo(function OpenInPicker({ enableShortcut?: boolean; }) { const openInEditorMutation = useAtomCommand(shellEnvironment.openInEditor, "open in editor"); - const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); + const remote = useRemoteOpenState(environmentId); + const remoteCapableEditors = useRemoteCapableEditors(); + const [remoteHintSeen, markRemoteHintSeen] = useRemoteOpenHint(); + const environmentLabel = useEnvironment(environmentId)?.label ?? "this machine"; + // Remote mode ignores the server's PATH probe: what matters is what runs on + // the viewing machine, which only the desktop app can probe. + const effectiveEditors = remote.mode === "local-exec" ? availableEditors : remoteCapableEditors; + const [preferredEditor, setPreferredEditor] = usePreferredEditor(effectiveEditors); const options = useMemo( - () => resolveOptions(navigator.platform, availableEditors), - [availableEditors], + () => resolveOptions(navigator.platform, effectiveEditors), + [effectiveEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; @@ -211,6 +230,23 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; + if (remote.mode === "remote-unavailable") return; + if (remote.mode === "remote-links") { + const url = buildRemoteOpenUrl({ + editor, + host: remote.host.host, + absolutePath: openInCwd, + }); + if (url === undefined) return; + // Only record hint-seen/preferred when the shell actually accepted + // the URL (an older desktop build can refuse the editor scheme). + void openRemoteEditorUrl(url).then((opened) => { + if (!opened) return; + markRemoteHintSeen(); + setPreferredEditor(editor); + }); + return; + } const result = openInEditorMutation({ environmentId, input: { @@ -221,7 +257,15 @@ export const OpenInPicker = memo(function OpenInPicker({ setPreferredEditor(editor); return result; }, - [environmentId, openInCwd, openInEditorMutation, preferredEditor, setPreferredEditor], + [ + environmentId, + markRemoteHintSeen, + openInCwd, + openInEditorMutation, + preferredEditor, + remote, + setPreferredEditor, + ], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -237,24 +281,11 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!preferredEditor) return; e.preventDefault(); - void openInEditorMutation({ - environmentId, - input: { - cwd: openInCwd, - editor: preferredEditor, - }, - }); + void openInEditor(preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [ - enableShortcut, - environmentId, - keybindings, - openInCwd, - openInEditorMutation, - preferredEditor, - ]); + }, [enableShortcut, keybindings, openInCwd, openInEditor, preferredEditor]); return ( @@ -263,7 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ className="ps-[8.5px]" size="xs" variant="outline" - disabled={!preferredEditor || !openInCwd} + disabled={!preferredEditor || !openInCwd || remote.mode === "remote-unavailable"} onClick={() => openInEditor(preferredEditor)} > {primaryOption?.Icon && ( @@ -296,16 +327,25 @@ export const OpenInPicker = memo(function OpenInPicker({
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index c4ca57b80..19bf2d5ad 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -20,6 +20,7 @@ import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPre import { useAssetUrlState } from "~/assets/assetUrls"; import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; +import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; @@ -771,6 +772,7 @@ export default function FilePreviewPanel({ const { resolvedTheme } = useTheme(); const wordWrap = useClientSettings((settings) => settings.wordWrap); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const remoteOpenState = useRemoteOpenState(environmentId); const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(environmentId); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -893,7 +895,8 @@ export default function FilePreviewPanel({ ))}
- {absolutePath && environmentId === primaryEnvironmentId ? ( + {absolutePath && + (environmentId === primaryEnvironmentId || remoteOpenState.mode !== "local-exec") ? ( + new PrimaryConnectionTarget({ + environmentId, + label: "sol", + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace("http", "ws"), + }); + +const TAILSCALE_TARGETS = [ + { kind: "tailscale", host: "sol.tail1234.ts.net" }, + { kind: "mdns", host: "sol.local" }, +] as const; + +describe("resolveRemoteOpenState", () => { + it("keeps exec behavior for a loopback primary target", () => { + expect( + resolveRemoteOpenState({ + target: primaryTarget("http://127.0.0.1:8000"), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("uses deep links for a primary target reached over the network", () => { + expect( + resolveRemoteOpenState({ + target: primaryTarget("https://sol.tail1234.ts.net"), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ + mode: "remote-links", + host: { kind: "tailscale", host: "sol.tail1234.ts.net" }, + }); + }); + + it("keeps exec behavior for the desktop app's own primary even on a NAT URL", () => { + // wsl-only mode binds the primary to the WSL2 NAT address; it is still + // this machine because the desktop app manages its own primary backend. + expect( + resolveRemoteOpenState({ + target: primaryTarget("http://172.29.112.1:14369"), + sshAlias: null, + isDesktopRenderer: true, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("keeps exec behavior for desktop-local secondary backends", () => { + expect( + resolveRemoteOpenState({ + target: new BearerConnectionTarget({ + environmentId, + label: "WSL (Ubuntu)", + connectionId: "local:wsl-1", + }), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "local-exec" }); + }); + + it("prefers the desktop SSH alias over server-advertised hosts", () => { + expect( + resolveRemoteOpenState({ + target: new SshConnectionTarget({ + environmentId, + label: "sol", + connectionId: "ssh-1", + }), + sshAlias: "sol", + isDesktopRenderer: true, + remoteOpenTargets: TAILSCALE_TARGETS, + }), + ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); + }); + + it("reports unavailable when a remote environment advertises no hosts", () => { + for (const remoteOpenTargets of [[], undefined] as const) { + expect( + resolveRemoteOpenState({ + target: new RelayConnectionTarget({ environmentId, label: "sol" }), + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets, + }), + ).toEqual({ mode: "remote-unavailable" }); + } + }); + + it("falls back to exec when the environment has no catalog entry", () => { + expect( + resolveRemoteOpenState({ + target: null, + sshAlias: null, + isDesktopRenderer: false, + remoteOpenTargets: undefined, + }), + ).toEqual({ mode: "local-exec" }); + }); +}); + +describe("buildRemoteOpenUrl", () => { + it("builds a vscode-remote deep link", () => { + expect( + buildRemoteOpenUrl({ + editor: "vscode", + host: "sol.tail1234.ts.net", + absolutePath: "/home/theo/code/my repo", + }), + ).toBe("vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/code/my%20repo"); + }); + + it("uses the fork's scheme", () => { + expect(buildRemoteOpenUrl({ editor: "cursor", host: "sol", absolutePath: "/tmp/x" })).toBe( + "cursor://vscode-remote/ssh-remote+sol/tmp/x", + ); + }); + + it("roots Windows paths", () => { + expect( + buildRemoteOpenUrl({ editor: "vscode", host: "sol", absolutePath: "C:\\Users\\theo" }), + ).toBe("vscode://vscode-remote/ssh-remote+sol/C%3A/Users/theo"); + }); + + it("returns undefined for editors without remote support", () => { + expect(buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "/tmp/x" })).toBe( + undefined, + ); + }); +}); diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts new file mode 100644 index 000000000..dff8e9afa --- /dev/null +++ b/apps/web/src/remoteOpen.ts @@ -0,0 +1,189 @@ +/** + * Remote open-in-editor: when this client is not on the environment's + * machine, "Open" must hand the OS a `vscode://vscode-remote/ssh-remote+…` + * deep link (local editor connects over SSH) instead of exec'ing an editor + * on the environment host. + * + * Host precedence: a desktop-SSH environment's real `~/.ssh/config` alias + * beats server-advertised names; among advertised names the tailnet MagicDNS + * name beats mDNS `.local` (server sends them in that order). + */ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import { + REMOTE_CAPABLE_EDITOR_IDS, + type EditorId, + type EnvironmentId, + type RemoteOpenTarget, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { useEffect, useMemo, useState } from "react"; + +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; +import { isLoopbackHostname } from "~/environments/primary/target"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { useEnvironmentPresentation } from "~/state/presentation"; + +export interface RemoteOpenHost { + readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; + readonly host: string; +} + +export type RemoteOpenState = + | { readonly mode: "local-exec" } + | { readonly mode: "remote-links"; readonly host: RemoteOpenHost } + | { readonly mode: "remote-unavailable" }; + +export type RemoteOpenMode = RemoteOpenState["mode"]; + +const LOCAL_EXEC: RemoteOpenState = { mode: "local-exec" }; +const REMOTE_UNAVAILABLE: RemoteOpenState = { mode: "remote-unavailable" }; + +function parseHostname(url: string): string | null { + try { + return new URL(url).hostname; + } catch { + return null; + } +} + +export function resolveRemoteOpenState(input: { + readonly target: ConnectionTarget | null; + /** Real ssh alias for desktop-SSH environments; null elsewhere. */ + readonly sshAlias: string | null; + /** Server-advertised hosts; undefined on servers that predate the feature. */ + readonly remoteOpenTargets: ReadonlyArray | undefined; + /** True when running inside the desktop app's renderer. */ + readonly isDesktopRenderer: boolean; +}): RemoteOpenState { + const { target } = input; + // No catalog entry: keep today's exec behavior rather than guessing. + if (target === null) { + return LOCAL_EXEC; + } + if (target._tag === "PrimaryConnectionTarget") { + // The desktop app manages its own primary backend, so it is always on + // this machine even when its URL is not loopback (wsl-only mode binds + // the WSL2 NAT address). In a browser, a loopback primary means the + // browser runs on the serving machine; a tailnet/LAN URL means remote. + if (input.isDesktopRenderer) { + return LOCAL_EXEC; + } + const hostname = parseHostname(target.httpBaseUrl); + if (hostname !== null && isLoopbackHostname(hostname)) { + return LOCAL_EXEC; + } + } else if (isDesktopLocalConnectionTarget(target)) { + return LOCAL_EXEC; + } + + if (input.sshAlias !== null && input.sshAlias.length > 0) { + return { mode: "remote-links", host: { kind: "ssh-alias", host: input.sshAlias } }; + } + const advertised = input.remoteOpenTargets?.[0]; + if (advertised !== undefined) { + return { mode: "remote-links", host: advertised }; + } + return REMOTE_UNAVAILABLE; +} + +export function useRemoteOpenState(environmentId: EnvironmentId | null): RemoteOpenState { + const { presentation } = useEnvironmentPresentation(environmentId); + + return useMemo(() => { + if (presentation === null) { + return LOCAL_EXEC; + } + const profile = Option.getOrNull(presentation.entry.profile); + const sshAlias = + profile !== null && profile._tag === "SshConnectionProfile" ? profile.target.alias : null; + return resolveRemoteOpenState({ + target: presentation.entry.target, + sshAlias, + remoteOpenTargets: presentation.serverConfig?.remoteOpenTargets, + isDesktopRenderer: window.desktopBridge !== undefined, + }); + }, [presentation]); +} + +/** + * Editors offered in remote-link mode. The desktop app probes the machine the + * renderer runs on; a browser cannot, so it offers VS Code only. + */ +const REMOTE_FALLBACK_EDITORS: ReadonlyArray = ["vscode"]; + +let cachedProbedEditors: ReadonlyArray | null = null; + +export function __resetRemoteEditorProbeForTests(): void { + cachedProbedEditors = null; +} + +export function useRemoteCapableEditors(): ReadonlyArray { + const [editors, setEditors] = useState>( + () => cachedProbedEditors ?? REMOTE_FALLBACK_EDITORS, + ); + + useEffect(() => { + if (cachedProbedEditors !== null) { + return; + } + const probe = window.desktopBridge?.probeRemoteEditors; + if (probe === undefined) { + cachedProbedEditors = REMOTE_FALLBACK_EDITORS; + return; + } + let cancelled = false; + probe().then( + (ids) => { + const remoteCapable = ids.filter((id) => REMOTE_CAPABLE_EDITOR_IDS.includes(id)); + cachedProbedEditors = remoteCapable.length > 0 ? remoteCapable : REMOTE_FALLBACK_EDITORS; + if (!cancelled) { + setEditors(cachedProbedEditors); + } + }, + () => { + cachedProbedEditors = REMOTE_FALLBACK_EDITORS; + }, + ); + return () => { + cancelled = true; + }; + }, []); + + return editors; +} + +/** + * Fire a remote editor deep link. In desktop, route through the Electron + * shell so the OS handler opens without navigating the renderer; in a + * browser, assign the location — unlike window.open this does not leave a + * blank tab behind. + * + * Resolves false when the desktop shell refused the URL (e.g. an older + * build whose protocol allowlist predates editor schemes) so callers do not + * record a successful open that never happened. + */ +export async function openRemoteEditorUrl(url: string): Promise { + const bridge = window.desktopBridge; + if (bridge !== undefined) { + try { + return await bridge.openExternal(url); + } catch { + return false; + } + } + window.location.assign(url); + return true; +} + +/** + * One-time "you need SSH keys on that machine" hint, shown in the picker menu + * until the first remote open fires (we cannot observe SSH success from here, + * so first click is the dismiss signal). + */ +const REMOTE_OPEN_HINT_KEY = "t3code:remote-open-hint-seen"; + +export function useRemoteOpenHint(): readonly [seen: boolean, markSeen: () => void] { + const [seen, setSeen] = useLocalStorage(REMOTE_OPEN_HINT_KEY, false, Schema.Boolean); + return [seen, () => setSeen(true)] as const; +} diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 5948d87e1..d714a0e02 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -10,20 +10,45 @@ type EditorDefinition = { readonly commands: readonly [string, ...string[]] | null; readonly baseArgs?: readonly string[]; readonly launchStyle: EditorLaunchStyle; + /** + * URL scheme for editors that support VS Code's remote deep links + * (`://vscode-remote/ssh-remote+`). Only set for VS Code + * and forks that ship the Remote-SSH machinery. + */ + readonly remoteScheme?: string; }; export const EDITORS = [ - { id: "cursor", label: "Cursor", commands: ["cursor"], launchStyle: "goto" }, + { + id: "cursor", + label: "Cursor", + commands: ["cursor"], + launchStyle: "goto", + remoteScheme: "cursor", + }, { id: "trae", label: "Trae", commands: ["trae"], launchStyle: "goto" }, { id: "kiro", label: "Kiro", commands: ["kiro"], baseArgs: ["ide"], launchStyle: "goto" }, - { id: "vscode", label: "VS Code", commands: ["code"], launchStyle: "goto" }, + { + id: "vscode", + label: "VS Code", + commands: ["code"], + launchStyle: "goto", + remoteScheme: "vscode", + }, { id: "vscode-insiders", label: "VS Code Insiders", commands: ["code-insiders"], launchStyle: "goto", + remoteScheme: "vscode-insiders", + }, + { + id: "vscodium", + label: "VSCodium", + commands: ["codium"], + launchStyle: "goto", + remoteScheme: "vscodium", }, - { id: "vscodium", label: "VSCodium", commands: ["codium"], launchStyle: "goto" }, { id: "zed", label: "Zed", commands: ["zed", "zeditor"], launchStyle: "direct-path" }, { id: "antigravity", label: "Antigravity", commands: ["agy"], launchStyle: "goto" }, { id: "idea", label: "IntelliJ IDEA", commands: ["idea"], launchStyle: "line-column" }, @@ -50,6 +75,54 @@ export const LaunchEditorInput = Schema.Struct({ }); export type LaunchEditorInput = typeof LaunchEditorInput.Type; +const remoteSchemeOf = (editor: EditorDefinition): string | undefined => editor.remoteScheme; + +/** Editors that can open a remote workspace via `vscode-remote` deep links. */ +export const REMOTE_CAPABLE_EDITOR_IDS: ReadonlyArray = EDITORS.flatMap((editor) => + remoteSchemeOf(editor) !== undefined ? [editor.id] : [], +); + +export const remoteSchemeForEditor = (id: EditorId): string | undefined => { + const editor = EDITORS.find((candidate) => candidate.id === id); + return editor === undefined ? undefined : remoteSchemeOf(editor); +}; + +/** + * Builds a `://vscode-remote/ssh-remote+` deep link that + * opens `absolutePath` on `host` in the local editor over SSH. Returns + * undefined for editors without remote deep-link support. + */ +export const buildRemoteOpenUrl = (input: { + readonly editor: EditorId; + readonly host: string; + readonly absolutePath: string; +}): string | undefined => { + const scheme = remoteSchemeForEditor(input.editor); + if (scheme === undefined) { + return undefined; + } + // Windows server paths (`C:\...`) appear as `/C:/...` in vscode-remote URIs. + const posixPath = input.absolutePath.replaceAll("\\", "/"); + const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; + const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); + return `${scheme}://vscode-remote/ssh-remote+${encodeURIComponent(input.host)}${encodedPath}`; +}; + +/** + * SSH hostnames an environment advertises for remote open links. Reachability + * is client-side; the server only advertises names that resolve to itself and + * gates them on a local sshd listen check. Ordered most-reachable first + * (tailnet MagicDNS name, then mDNS `.local`). + */ +export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); +export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; + +export const RemoteOpenTarget = Schema.Struct({ + kind: RemoteOpenTargetKind, + host: TrimmedNonEmptyString, +}); +export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; + export class ExternalLauncherUnknownEditorError extends Schema.TaggedErrorClass()( "ExternalLauncherUnknownEditorError", { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f99d4d34b..09d7d7a46 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -92,6 +92,7 @@ import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } fr import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import type { ClientSettings } from "./settings.ts"; +import type { EditorId } from "./editor.ts"; import type { SourceControlCloneRepositoryInput, SourceControlCloneRepositoryResult, @@ -1072,6 +1073,12 @@ export interface DesktopBridge { position?: { x: number; y: number }, ) => Promise; openExternal: (url: string) => Promise; + /** + * Probe this desktop machine for installed remote-capable editor CLIs + * (used for remote open-in-editor deep links). Optional: older desktop + * builds lack it; callers fall back to VS Code only. + */ + probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index cbb74bc02..59a4fe6c1 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -17,7 +17,7 @@ import { KeybindingWhen, ResolvedKeybindingsConfig, } from "./keybindings.ts"; -import { EditorId } from "./editor.ts"; +import { EditorId, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { RuntimeMode } from "./orchestration.ts"; import { ProviderFeatureCapabilities } from "./providerCapabilities.ts"; @@ -556,6 +556,12 @@ export const ServerConfig = Schema.Struct({ // Editor ids grow over time; drop ones this build does not know rather than // failing the whole config decode. availableEditors: ForwardCompatibleArray(EditorId), + /** + * SSH hosts this environment advertises for remote open-in-editor links. + * Absent on servers that predate the feature; empty when the machine has no + * sshd or no advertisable name. + */ + remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), observability: ServerObservability, settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d7713a726..464457629 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -39,6 +39,12 @@ export interface NetServiceShape { */ readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect; + /** + * Returns true when something accepts TCP connections on {host, port}. + * Unlike the bind-side checks this works for privileged ports (<1024). + */ + readonly hasListenerOnHost: (port: number, host: string) => Effect.Effect; + /** * Reserve an ephemeral loopback port and release it immediately. */ @@ -183,6 +189,7 @@ export const make = () => { return { canListenOnHost, isPortAvailableOnLoopback, + hasListenerOnHost, reserveLoopbackPort, findAvailablePort: (preferred) => Effect.gen(function* () { diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 4c2ecb331..76b8ecccb 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -80,6 +80,7 @@ const hangingHttpClient = HttpClient.make(() => Effect.never); const testNetService = NetService.NetService.of({ canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(41_773), findAvailablePort: (preferred) => Effect.succeed(preferred), }); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 6914ebb69..9b4f44475 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -35,6 +35,7 @@ const emptyConfigLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} } const netServiceLayer = Layer.succeed(NetService.NetService, { canListenOnHost: () => Effect.succeed(true), isPortAvailableOnLoopback: () => Effect.succeed(true), + hasListenerOnHost: () => Effect.succeed(false), reserveLoopbackPort: () => Effect.succeed(49_152), findAvailablePort: (port) => Effect.succeed(port), }); From c1571b11c08875b86bc218248ef96c603be9910b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 14 Aug 2026 22:48:08 -0400 Subject: [PATCH 07/99] feat(web): older chat timestamps show the date, not just the time (#6654) Co-authored-by: Claude Fable 5 (cherry picked from commit 48ddb3d469d245363dba723f774ba449e4d950c5) --- .../src/components/chat/MessagesTimeline.tsx | 6 +-- apps/web/src/timestampFormat.test.ts | 50 +++++++++++++++++++ apps/web/src/timestampFormat.ts | 38 ++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 89331403f..6d1fda890 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -104,7 +104,7 @@ import { import { cn } from "~/lib/utils"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; -import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; +import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { DotMatrix, type DotMatrixState } from "../ui/dot-matrix"; import { @@ -1047,7 +1047,7 @@ function UserTimelineRow({ row }: { row: Extract }> - {formatShortTimestamp(row.message.createdAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} @@ -1147,7 +1147,7 @@ function AssistantTimelineRow({ row }: { row: Extract} > - {formatShortTimestamp(row.message.updatedAt, ctx.timestampFormat)} + {formatDayAwareTimestamp(row.message.updatedAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.updatedAt, ctx.timestampFormat)} diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c2fe4b627..6678549cc 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + formatDayAwareTimestamp, formatElapsedDurationLabel, formatExpiresInLabel, formatRelativeTime, @@ -96,6 +97,55 @@ describe("formatExpiresInLabel", () => { }); }); +describe("formatDayAwareTimestamp", () => { + // Instants are built with the local-time Date constructor so the + // calendar-day boundaries hold in any test timezone or locale. + const iso = (y: number, monthIndex: number, d: number, h: number, mi: number) => + new Date(y, monthIndex, d, h, mi).toISOString(); + const now = new Date(2026, 7, 14, 12, 0).getTime(); + const time = (isoDate: string) => formatShortTimestamp(isoDate, "12-hour"); + + it("shows time only for today", () => { + const messageAt = iso(2026, 7, 14, 9, 30); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe(time(messageAt)); + }); + + it("labels the previous calendar day as yesterday even when under 24h old", () => { + const messageAt = iso(2026, 7, 13, 23, 30); + const justPastMidnight = new Date(2026, 7, 14, 0, 30).getTime(); + expect(formatDayAwareTimestamp(messageAt, "12-hour", justPastMidnight)).toBe( + `yesterday at ${time(messageAt)}`, + ); + }); + + it("prefixes older same-year messages with the numeric date", () => { + const messageAt = iso(2026, 7, 12, 12, 34); + const datePart = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + }).format(new Date(messageAt)); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( + `${datePart} ${time(messageAt)}`, + ); + }); + + it("includes the year once the calendar year differs", () => { + const messageAt = iso(2025, 11, 31, 18, 0); + const datePart = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", + }).format(new Date(messageAt)); + expect(formatDayAwareTimestamp(messageAt, "12-hour", now)).toBe( + `${datePart} ${time(messageAt)}`, + ); + }); + + it("returns an empty string for invalid input", () => { + expect(formatDayAwareTimestamp("not-a-date", "12-hour", now)).toBe(""); + }); +}); + describe("invalid timestamp inputs", () => { it("returns an empty timestamp instead of throwing", () => { expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index cce5b141c..c8f9956eb 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -91,6 +91,44 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp return getTimestampFormatter(timestampFormat, false).format(date); } +const numericDateFormatter = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", +}); +const numericDateWithYearFormatter = new Intl.DateTimeFormat(undefined, { + month: "numeric", + day: "numeric", + year: "numeric", +}); + +/** + * Chat timestamp that adds the date once the message is no longer from today: + * today `12:34 PM`, yesterday `yesterday at 12:34 PM`, older `8/13 12:34 PM` + * (locale digit order), with the year included once the calendar year differs. + * Boundaries are local calendar days, not 24-hour windows. + */ +export function formatDayAwareTimestamp( + isoDate: string, + timestampFormat: TimestampFormat, + nowMs: number = Date.now(), +): string { + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const time = getTimestampFormatter(timestampFormat, false).format(date); + + const now = new Date(nowMs); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + // Round so DST-shifted 23/25 hour days still count as whole days. + const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000); + + if (dayDiff <= 0) return time; + if (dayDiff === 1) return `yesterday at ${time}`; + const dateFormatter = + date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter; + return `${dateFormatter.format(date)} ${time}`; +} + /** * Format a relative time string from an ISO date. * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` From 0c42a64080025501061bb67aacabe890924fec87 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:48:54 +0300 Subject: [PATCH 08/99] fix(web): align pull request action menu rows (#6534) Co-authored-by: Nickolas Kyryliuk Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit 8c628f14993cb159d467e7a0f8c52578dde77005) --- .../pullRequest/PullRequestDetailPanel.tsx | 42 +++++++++++++++---- .../pullRequestDetail.logic.test.ts | 7 ++++ .../pullRequest/pullRequestDetail.logic.ts | 9 ++++ apps/web/src/components/ui/menu.test.tsx | 23 ++++++++++ apps/web/src/components/ui/menu.tsx | 2 +- 5 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/ui/menu.test.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2f4e84dc3..015371a86 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -103,6 +103,7 @@ import { handoffPrompt, handoffReviewComments, pullRequestActionNeedsHostRefresh, + pullRequestActionMenuHasGroup, pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, @@ -1033,6 +1034,26 @@ export function PullRequestDetailPanel({ : allowedMergeMethods.length > 0 ? "merge" : null; + // What the menu's action group holds. Named once so the separators around it are drawn from + // the same answer as its contents, rather than on the assumption that it has any. + const showsDraftToggle = + detail?.state === "open" && + can(detail.isDraft ? "ready" : "draft") && + !(detail.isDraft && primaryAction === "ready"); + const showsAutoMerge = + detail?.state === "open" && + ((autoMergeArmed && can("disable-auto-merge")) || + (!autoMergeArmed && + !detail.isDraft && + !conflicting && + can("enable-auto-merge") && + allowedMergeMethods.length > 0)); + const showsMergeMethods = + detail?.state === "open" && + can("merge") && + !detail.isDraft && + !conflicting && + allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors // it. Conflicts keep their own row below: an open pull request remains green there. const statePresentation = detail @@ -1191,8 +1212,7 @@ export function PullRequestDetailPanel({ {/* Only where the button row could not take it: "Ready for review" on a draft is the primary header button, so offering it here as well would show the same action twice. */} - {can(detail.isDraft ? "ready" : "draft") && - !(detail.isDraft && primaryAction === "ready") ? ( + {showsDraftToggle ? ( void perform(detail.isDraft ? "ready" : "draft")} @@ -1236,12 +1256,12 @@ export function PullRequestDetailPanel({ Hidden while conflicting: every method would fail. */} {/* Only where merging is on offer at all: a strategy to merge with is not a choice for someone who may not merge. */} - {can("merge") && - !detail.isDraft && - !conflicting && - allowedMergeMethods.length > 1 ? ( + {showsMergeMethods ? ( <> - + {/* Only below the draft control. A host with no draft of its own, or + a draft whose control is already the header button, would leave + this against the separator that opened the group. */} + {showsDraftToggle ? : null} @@ -1261,7 +1281,13 @@ export function PullRequestDetailPanel({ ) : null} - + {pullRequestActionMenuHasGroup( + showsDraftToggle, + showsAutoMerge, + showsMergeMethods, + ) ? ( + + ) : null} ) : null} void readLocalApi()?.shell.openExternal(detail.url)}> diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 9b247002f..faab9d847 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -19,6 +19,7 @@ import { isThreadOwnPullRequest, orderPullRequestComments, pullRequestActionNeedsHostRefresh, + pullRequestActionMenuHasGroup, pullRequestFindingKey, pullRequestHandoffLabels, readableFailure, @@ -53,6 +54,12 @@ const TIMELINE_SOURCE: Pick< closedAt: null, }; +describe("pull request action menu", () => { + it("keeps the group divider when auto-merge is the only action", () => { + expect(pullRequestActionMenuHasGroup(false, true, false)).toBe(true); + }); +}); + describe("pull request state description", () => { it("keeps draft and conflicts orthogonal to the terminal states", () => { expect(describePullRequestState("open", true)).toBe("Draft"); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index ddb4e813b..26054f6ef 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -57,6 +57,15 @@ export function pullRequestHandoffLabels(inThisThread: boolean) { }; } +/** Whether the open pull-request action group contains at least one action. */ +export function pullRequestActionMenuHasGroup( + showsDraftToggle: boolean, + showsAutoMerge: boolean, + showsMergeMethods: boolean, +): boolean { + return showsDraftToggle || showsAutoMerge || showsMergeMethods; +} + /** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { if (state === "merged") return "Merged"; diff --git a/apps/web/src/components/ui/menu.test.tsx b/apps/web/src/components/ui/menu.test.tsx new file mode 100644 index 000000000..079d2a179 --- /dev/null +++ b/apps/web/src/components/ui/menu.test.tsx @@ -0,0 +1,23 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { Menu, MenuRadioGroup, MenuRadioItem } from "./menu"; + +describe("menu radio item geometry", () => { + it("keeps radio-item icons on the same text grid as menu items", () => { + const html = renderToStaticMarkup( + + + + + + Merge + + + + , + ); + + expect(html).toContain("-mx-0.5"); + }); +}); diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 803d6c198..b66782ebe 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -166,7 +166,7 @@ function MenuRadioItem({ return ( Date: Sat, 15 Aug 2026 09:33:08 +0200 Subject: [PATCH 09/99] fix(web): restore selected themes in dark mode (#6665) (cherry picked from commit 6ae9662d8ed215476e697adc12403ce035500828) --- apps/web/src/index.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 295a1c5b4..49d27983c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1222,7 +1222,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil compatibility overrides so both navigation implementations receive the same palette. Success, info, provider, and channel identity colors remain independent; error, warning, and update roles are themeable below. */ -html[data-theme-id] { +html[data-theme-id], +html.dark[data-theme-id] { --background: var(--app-theme-canvas); --app-chrome-background: var(--app-theme-chrome); --toolbar-background: var(--app-theme-toolbar); From e4fbc5f0ed2a5704d879ff885e876e93b410029f Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:42:35 +0200 Subject: [PATCH 10/99] fix(web): improve Codex usage graph contrast (#6669) (cherry picked from commit f0ebc628c6dd83fd0c7963078ad7778ce6028d0c) --- apps/web/src/components/usage/UsagePage.tsx | 12 ++--- .../components/usage/UsageProviderChart.tsx | 23 +++++---- .../src/components/usage/usageProviders.ts | 47 +++++++++---------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7a5cdd883..92e2c5b6f 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -25,7 +25,7 @@ import { SidebarInset } from "../ui/sidebar"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const WINDOW_OPTIONS = [ { days: 1, label: "Past 24h" }, @@ -209,7 +209,7 @@ export function UsagePage() {
- {PROVIDER_LABEL[provider.provider]} + {PROVIDER_PRESENTATION[provider.provider].label} {metric === "cost" @@ -222,7 +222,7 @@ export function UsagePage() { className="h-full" style={{ width: `${(share * 100).toFixed(1)}%`, - backgroundColor: PROVIDER_COLOR[provider.provider], + backgroundColor: PROVIDER_PRESENTATION[provider.provider].color, }} />
@@ -385,7 +385,7 @@ export function UsagePage() { {isPast24Hours ? "Hour" : "Day"} {PROVIDER_ORDER.map((provider) => ( - {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label} ))} Total @@ -448,7 +448,7 @@ function ProviderMark({ readonly provider: UsageProviderKind; readonly className: string; }) { - const Mark = PROVIDER_MARK[provider]; + const Mark = PROVIDER_PRESENTATION[provider].mark; return ; } @@ -595,7 +595,7 @@ function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" })
- {PROVIDER_LABEL[provider]} + {PROVIDER_PRESENTATION[provider].label}
diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index f41945bfe..d7582a0e4 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -9,7 +9,7 @@ import { formatTokens, formatUsd, } from "@t3tools/shared/usageFormat"; -import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; +import { PROVIDER_ORDER, PROVIDER_PRESENTATION } from "./usageProviders"; const VIEW_WIDTH = 960; const VIEW_HEIGHT = 260; @@ -339,14 +339,19 @@ export function UsageProviderChart({ {/* Fills first, then every stroke, so no series covers another's line. */} {paths.map(({ provider, area }) => ( - + ))} {paths.map(({ provider, line }) => ( @@ -376,12 +381,12 @@ export function UsageProviderChart({ >
{formatTooltipPeriod(hoveredPeriod)}
{PROVIDER_ORDER.map((provider) => { - const Mark = PROVIDER_MARK[provider]; + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return (
- {PROVIDER_LABEL[provider]} + {label} {format( @@ -423,13 +428,13 @@ export function UsageChartLegend() { return (
{PROVIDER_ORDER.map((provider) => { - // The marks carry the same fills as the bands, so they key the chart - // just as a colour swatch would. - const Mark = PROVIDER_MARK[provider]; + // Brand marks keep monochrome providers identifiable even when their + // chart series use distinct colors. + const { label, mark: Mark } = PROVIDER_PRESENTATION[provider]; return ( - {PROVIDER_LABEL[provider]} + {label} ); })} diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index f8b65877d..00db67e28 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -2,32 +2,29 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { ClaudeAI, type Icon, OpenAI } from "../Icons"; -/** - * Series and table order. The chart layers both providers from a shared zero - * baseline, so this only fixes the reading order of legends, tables and hover - * rows; it does not decide which series sits above the other. - */ -export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; - -export const PROVIDER_LABEL: Record = { - claude: "Claude Code", - codex: "Codex", -}; - -/** Claude's brand orange against a neutral white for Codex. */ -export const PROVIDER_COLOR: Record = { - claude: "#d97757", - codex: "#e6e6e6", +type UsageProviderPresentation = { + readonly label: string; + readonly color: string; + readonly mark: Icon; }; /** - * Brand marks, reused from the provider picker. - * - * These ship their own fills (`#d97757` for Claude, white on dark for OpenAI), - * which are the same colours as the chart bands, so swapping a colour dot for a - * mark keeps the series association intact rather than trading it away. + * Exhaustive presentation for providers supported by the usage contract. + * Declaration order is reused by every chart, table, legend, and skeleton, so + * adding a provider only requires its contract support and one entry here. */ -export const PROVIDER_MARK: Record = { - claude: ClaudeAI, - codex: OpenAI, -}; +export const PROVIDER_PRESENTATION = { + codex: { + label: "Codex", + color: "var(--foreground)", + mark: OpenAI, + }, + claude: { + label: "Claude Code", + color: "#d97757", + mark: ClaudeAI, + }, +} satisfies Record; + +/** The chart layers every series from zero, so order only controls how it is read. */ +export const PROVIDER_ORDER = Object.keys(PROVIDER_PRESENTATION) as UsageProviderKind[]; From 97096ffc11e39403d5967515c0da799f67717229 Mon Sep 17 00:00:00 2001 From: Trevor Walker Date: Sat, 15 Aug 2026 13:03:17 -0600 Subject: [PATCH 11/99] fix(desktop): app zoom no longer zooms the preview browser (#6649) Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit d8a6dfd31539a86d08bd4fbd030f8252b3c405ac) --- apps/desktop/src/preview/Manager.test.ts | 134 ++++++++++++++++-- apps/desktop/src/preview/Manager.ts | 72 +++++++--- apps/desktop/src/window/DesktopWindow.test.ts | 47 ++++++ apps/desktop/src/window/DesktopWindow.ts | 4 + 4 files changed, 219 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 2e30c45e6..7b0af84ac 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -979,7 +979,10 @@ describe("PreviewManager", () => { ), ); - effectIt.effect("mirrors Electron's effective zoom across registration and navigation", () => + // The guest reports whatever zoom level Chromium handed it from the app + // window, so the tab's own zoom is the source of truth in both directions: + // asserted onto every guest, never read back off one. + effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () => withManager((manager) => Effect.gen(function* () { let effectiveZoom = 0.9; @@ -1025,18 +1028,13 @@ describe("PreviewManager", () => { yield* manager.createTab("tab_zoom"); yield* manager.registerWebview("tab_zoom", 42); - expect(states.at(-1)?.zoomFactor).toBe(0.9); - expect(setZoomFactor).not.toHaveBeenCalled(); + expect(states.at(-1)?.zoomFactor).toBe(1); + expect(setZoomFactor).toHaveBeenCalledWith(1); - effectiveZoom = 1.25; - listeners.get("did-navigate")?.(); - yield* Effect.yieldNow; - - expect(states.at(-1)?.zoomFactor).toBe(1.25); - expect(setZoomFactor).not.toHaveBeenCalled(); - - zoomReadable = false; - url = "https://example.com/after-zoom-read-failed"; + // An app zoom leaves the guest reporting the inherited level. Navigating + // must not adopt it as the preview's zoom. + effectiveZoom = 0.8; + url = "https://example.com/after-app-zoom"; listeners.get("did-navigate")?.(); yield* Effect.yieldNow; @@ -1045,7 +1043,18 @@ describe("PreviewManager", () => { url, title: "Example", }); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(states.at(-1)?.zoomFactor).toBe(1); + + // Only the preview's own zoom controls move it. + yield* manager.zoomIn("tab_zoom"); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + + zoomReadable = false; + listeners.get("did-navigate")?.(); + yield* Effect.yieldNow; + + expect(states.at(-1)?.zoomFactor).toBe(1.1); const replacementSetZoomFactor = vi.fn(); fromId.mockReturnValue({ @@ -1074,8 +1083,103 @@ describe("PreviewManager", () => { yield* manager.registerWebview("tab_zoom", 43); - expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.25); - expect(states.at(-1)?.zoomFactor).toBe(1.25); + expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.at(-1)?.zoomFactor).toBe(1.1); + }), + ), + ); + + // Zooming the app UI pushes the window's zoom level onto every guest, so the + // preview has to be put back at the zoom the user gave it. + effectIt.effect("re-applies each tab's own zoom when the app window zooms", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_reapply"); + yield* manager.registerWebview("tab_reapply", 42); + yield* manager.zoomIn("tab_reapply"); + setZoomFactor.mockClear(); + + yield* manager.reapplyZoom(); + + expect(setZoomFactor).toHaveBeenCalledTimes(1); + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + }), + ), + ); + + // did-attach and dom-ready both re-register the guest that is already + // attached, and a guest that just inherited the app window's zoom needs its + // own back — without that round trip republishing tab state. + effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () => + withManager((manager) => + Effect.gen(function* () { + const setZoomFactor = vi.fn(); + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor, + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + const states: PreviewManager.PreviewTabState[] = []; + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + + yield* manager.createTab("tab_reregister_zoom"); + yield* manager.registerWebview("tab_reregister_zoom", 42); + yield* manager.zoomIn("tab_reregister_zoom"); + setZoomFactor.mockClear(); + const publishedBefore = states.length; + + yield* manager.registerWebview("tab_reregister_zoom", 42); + + expect(setZoomFactor).toHaveBeenCalledWith(1.1); + expect(states.length).toBe(publishedBefore); + expect(states.at(-1)?.zoomFactor).toBe(1.1); }), ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 4799a7dfa..d48b13037 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -647,6 +647,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function if (Option.isSome(next)) yield* emit(tabId, next.value); }); + /** + * Pushes a tab's zoom factor onto whichever guest it currently owns, reading + * both at call time. Anything that applies zoom after an await goes through + * here: a snapshot taken before the await can be older than a zoom action that + * landed in between, and re-applying it would roll that action back. + */ + const assertTabZoom = Effect.fn("PreviewManager.assertTabZoom")(function* (tabId: string) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab || tab.webContentsId == null) return; + const wc = webContents.fromId(tab.webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* attempt({ operation: "assertTabZoom", tabId, webContentsId: wc.id }, () => + wc.setZoomFactor(tab.zoomFactor), + ).pipe(Effect.ignore); + }); + const requireWebContents = Effect.fn("PreviewManager.requireWebContents")(function* ( tabId: string, ) { @@ -1305,10 +1321,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function confirmedNavigation = false, ) { if (wc.isDestroyed()) return; - const zoomFactor = yield* attempt( - { operation: "syncWebContentsState.getZoomFactor", tabId, webContentsId: wc.id }, - () => wc.getZoomFactor(), - ).pipe(Effect.option); const computedNavStatus = computeNavStatus(wc); const canGoBack = wc.navigationHistory.canGoBack(); const canGoForward = wc.navigationHistory.canGoForward(); @@ -1338,7 +1350,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus, canGoBack, canGoForward, - ...(Option.isSome(zoomFactor) ? { zoomFactor: zoomFactor.value } : {}), + // zoomFactor is deliberately not read back from the guest: Chromium + // reports the level it inherited from the app window, so mirroring it + // would turn an app zoom into the preview's own zoom. updatedAt, }; return [ @@ -1716,11 +1730,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const annotationTheme = yield* Ref.get(annotationThemeRef); const currentAttachment = attached.get(webContentsId); if (tab.webContentsId === webContentsId && currentAttachment?.webContents === wc) { - const zoomFactor = yield* attempt( - { operation: "registerWebview.getZoomFactor", tabId, webContentsId }, - () => wc.getZoomFactor(), - ); - yield* update(tabId, { zoomFactor }); + // The guest we already own re-announced itself, so nothing about the tab + // changed. Only push its zoom back down — Chromium may have just handed + // this guest the app window's zoom level. + yield* assertTabZoom(tabId); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => wc.send(ANNOTATION_THEME_CHANNEL, annotationTheme), ); @@ -1749,18 +1762,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { return yield* new PreviewTabNotFoundError({ tabId }); } - const zoomFactor = - replacedWebContentsId !== null - ? yield* attempt( - { operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, - () => { - wc.setZoomFactor(currentTab.zoomFactor); - return currentTab.zoomFactor; - }, - ) - : yield* attempt({ operation: "registerWebview.getZoomFactor", tabId, webContentsId }, () => - wc.getZoomFactor(), - ); + // Always assert the tab's own zoom rather than reading the guest's: a guest + // attaching while the app UI is zoomed starts at the embedder's inherited + // zoom level, which is not the preview's zoom. Done before the guest is + // published so it never paints a frame at the inherited zoom. + yield* attempt({ operation: "registerWebview.restoreZoomFactor", tabId, webContentsId }, () => + wc.setZoomFactor(currentTab.zoomFactor), + ); yield* attachListeners(tabId, wc); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modifyEffect(tabsRef, (tabs) => @@ -1784,7 +1792,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function navStatus: pendingUrl === null ? computeNavStatus(wc) : current.navStatus, canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward(), - zoomFactor, updatedAt: registeredAt, }; return [ @@ -1806,6 +1813,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return yield* new PreviewTabNotFoundError({ tabId }); } const { state: registered, pendingUrl } = registration.value; + // A zoom action that landed while this attach was in flight addressed the + // guest this one replaced, so settle the new guest on the committed factor. + yield* assertTabZoom(tabId); runFork(restoreControlSession(tabId, wc)); yield* emit(tabId, registered); yield* attempt({ operation: "registerWebview.sendTheme", tabId, webContentsId }, () => @@ -2099,6 +2109,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); + /** + * Chromium hands every guest `` the embedder's zoom level, so zooming + * the app UI drags the previewed page along with it. The preview browser owns + * its own zoom factor, so re-assert it on each attached guest whenever the main + * window's zoom changes (see DesktopWindow.zoomMain). + */ + const reapplyZoom = Effect.fn("PreviewManager.reapplyZoom")(function* () { + const tabIds = Array.from((yield* SynchronizedRef.get(tabsRef)).keys()); + yield* Effect.forEach(tabIds, assertTabZoom, { discard: true }); + }); + const applyZoom = Effect.fn("PreviewManager.applyZoom")(function* ( tabId: string, transform: (current: number) => number, @@ -3476,6 +3497,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function openPictureInPicture, openDevTools, pickElement, + reapplyZoom, refresh, registerWebview, resetZoom: (tabId: string) => applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR), @@ -3774,6 +3796,9 @@ export class PreviewManager extends Context.Service< readonly zoomIn: (tabId: string) => Effect.Effect; readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; + // Re-applies every attached guest's own zoom factor, undoing the zoom level + // Chromium inherits from the embedder when the app UI zooms. + readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, @@ -3874,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomIn: operations.zoomIn, zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, + reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index fb07470fe..840e0c803 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -61,9 +61,14 @@ const environmentInput = { function makeFakeBrowserWindow() { const windowListeners = new Map void>(); const webContentsListeners = new Map void>(); + let zoomLevel = 0; const webContents = { copyImageAt: vi.fn(), getURL: vi.fn(() => "pylon-code-dev://app/"), + getZoomLevel: vi.fn(() => zoomLevel), + setZoomLevel: vi.fn((level: number) => { + zoomLevel = level; + }), isLoadingMainFrame: vi.fn(() => false), on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => { webContentsListeners.set(eventName, listener); @@ -116,6 +121,7 @@ function makeFakeBrowserWindow() { openDevTools: webContents.openDevTools, reload: webContents.reload, send: webContents.send, + setZoomLevel: webContents.setZoomLevel, setAutoHideCursor: window.setAutoHideCursor, webContentsListeners, windowListeners, @@ -186,6 +192,7 @@ function makeTestLayer(input: { bounds: DesktopAppSettings.DesktopWindowBounds, ) => Effect.Effect; readonly openedExternalUrls?: unknown[]; + readonly previewZoomReapplies?: number[]; }) { let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS; const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { @@ -264,6 +271,10 @@ function makeTestLayer(input: { setMainWindow: () => Effect.void, isBrowserPartition: (partition) => partition.startsWith("persist:pylon-code-preview-"), getBrowserPartition: () => Effect.succeed("persist:pylon-code-preview-test"), + reapplyZoom: () => + Effect.sync(() => { + input.previewZoomReapplies?.push(input.window.webContents.getZoomLevel()); + }), }), ), ), @@ -483,6 +494,42 @@ describe("DesktopWindow", () => { }), ); + // Chromium hands the main window's zoom level down to embedded preview + // guests, so every app zoom has to put the preview browser back at its own + // zoom or zooming the UI drags the previewed page with it. + it.effect("restores the preview browser's own zoom after zooming the app", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const previewZoomReapplies: number[] = []; + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + previewZoomReapplies, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("out"); + yield* desktopWindow.zoomMain("in"); + yield* desktopWindow.zoomMain("reset"); + + assert.deepEqual( + fakeWindow.setZoomLevel.mock.calls.map(([level]) => level), + [-0.5, -1, -0.5, 0], + ); + // Recorded after the window level moved, so the preview is put back at + // its own zoom on every step rather than left on the inherited one. + assert.deepEqual(previewZoomReapplies, [-0.5, -1, -0.5, 0]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index bf8c68144..2ae3d3532 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -855,6 +855,10 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + // Chromium pushes the new level down to embedded guests, which would zoom + // the previewed page along with the app UI. The preview browser keeps its + // own zoom, so put each guest back where the preview left it. + yield* previewManager.reapplyZoom(); }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; From 0ab6714c29ad1e688fa44dedfd682380b885100d Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 15 Aug 2026 05:43:13 -0500 Subject: [PATCH 12/99] fix(server): keep provider notification consumers alive past startSession (#6538) Co-authored-by: tsouth89 (cherry picked from commit afca73d3683c99057ea8af1ad7d77511a0faf680) --- .../src/provider/Layers/CodexAdapter.test.ts | 58 ++++++++++++++++ .../src/provider/Layers/CodexAdapter.ts | 6 +- .../src/provider/Layers/CursorAdapter.test.ts | 68 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 8 ++- .../src/provider/Layers/GrokAdapter.test.ts | 67 ++++++++++++++++++ .../server/src/provider/Layers/GrokAdapter.ts | 8 ++- 6 files changed, 212 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 7b8fbec56..5358716aa 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -32,6 +32,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; @@ -1150,6 +1151,63 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { }); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the runtime event consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every event the session + // emitted afterwards was dropped. The other tests here start the session from + // the test fiber, which never completes, so the consumer survived and the bug + // stayed invisible. Starting it in a fiber that finishes reproduces + // production. + it.effect("keeps consuming runtime events after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const startSessionFiber = yield* adapter + .startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-outlives-start"), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber); + + const runtime = lifecycleRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + yield* runtime.emit({ + id: asEventId("evt-after-start-session"), + kind: "notification", + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method: "item/completed", + threadId: asThreadId("thread-outlives-start"), + turnId: asTurnId("turn-1"), + itemId: asItemId("msg_after_start"), + payload: { + completedAtMs: 1_778_000_000_000, + threadId: "thread-outlives-start", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "msg_after_start", + text: "emitted after startSession returned", + }, + }, + }); + + const firstEvent = yield* Fiber.join(firstEventFiber).pipe(Effect.timeout("10 seconds")); + NodeAssert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + NodeAssert.equal(firstEvent.value.type, "item.completed"); + // Live clock so the timeout above is real: under the default test clock it + // waits on virtual time that never advances, and a regression would hang + // until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); const scopedLifecycleRuntimeFactory = makeScopedRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index fdc753fef..8aea73619 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1717,6 +1717,10 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); + // Fork into the session scope, not the calling fiber. `forkChild` makes + // this a child of `startSession`, and Effect interrupts a fiber's + // children when it completes, so the consumer died on return and every + // runtime event the session emitted afterwards was dropped. const eventFiber = yield* Stream.runForEach(runtime.events, (event) => Effect.gen(function* () { yield* writeNativeEvent(event); @@ -1732,7 +1736,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( } yield* Queue.offerAll(runtimeEventQueue, runtimeEvents); }), - ).pipe(Effect.forkChild); + ).pipe(Effect.forkIn(sessionScope)); const started = yield* runtime.start().pipe( Effect.mapError( diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 491f718a9..cd5cdb7f0 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1429,4 +1429,72 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }).pipe(Effect.provide(customAdapterLayer)); }, ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. The other tests here call startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-consumer-outlives-start-session"); + + const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const sawContentDelta = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "content.delta" && String(event.threadId) === String(threadId) + ? Deferred.succeed(sawContentDelta, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello mock", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(sawContentDelta).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 80475a5c2..30c173d8f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -874,7 +874,13 @@ export function makeCursorAdapter( Effect.catch((cause) => Effect.logError("Failed to process Cursor runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 7b6f0972a..6cb71660a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -1197,4 +1197,71 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { yield* adapter.stopSession(threadId); }), ); + + // Production calls startSession from a request fiber that finishes as soon as + // the session exists. `Effect.forkChild` made the notification consumer a + // child of that fiber, and Effect interrupts a fiber's children when it + // completes, so the consumer died on return and every later session/update + // was dropped: the thread sat on "Working" forever while the provider + // streamed its whole turn. Every other test here calls startSession directly + // from the test fiber, which never completes, so the consumer survived and + // the bug stayed invisible. Running it in a fiber that finishes is what + // reproduces production. + it.effect("keeps consuming notifications after the startSession fiber completes", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-consumer-outlives-start-session"); + const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + runtimeEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" && String(event.threadId) === String(threadId) + ? Deferred.succeed(turnCompleted, undefined).pipe(Effect.asVoid) + : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const startSessionFiber = yield* adapter + .startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.forkChild); + yield* Fiber.join(startSessionFiber).pipe(Effect.timeout("10 seconds")); + + // Forked, and the assertion waits on the projected event rather than on + // sendTurn: with the consumer dead the turn never settles, so awaiting it + // directly would hang until the suite timeout instead of failing here. + const sendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hello grok", attachments: [] }) + .pipe(Effect.forkChild); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("10 seconds")); + yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("10 seconds")); + + const delta = runtimeEvents.find( + (event) => event.type === "content.delta" && String(event.threadId) === String(threadId), + ); + assert.isDefined( + delta, + "no content.delta was projected after the startSession fiber completed", + ); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + // Live clock so the timeouts above are real: under the default test clock + // they wait on virtual time that never advances, and a regression would + // hang until the suite timeout instead of failing here. + }).pipe(TestClock.withLive), + ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 977cc8caa..858d862e6 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -876,7 +876,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte Effect.catch((cause) => Effect.logError("Failed to process Grok runtime notification.", { cause }), ), - Effect.forkChild, + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), ); ctx.notificationFiber = nf; From db13d8e87a614a0870aafe13ae8baaf3eefd8ae1 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:43:20 +0200 Subject: [PATCH 13/99] fix(server): treat removed Bitbucket permissions endpoint as unknown, not blocking (#6525) (cherry picked from commit 75472802bc5ddaba860dc652000223600e529937) --- .../BitbucketPullRequestApi.test.ts | 40 +++++++++++++++++++ .../pullRequest/BitbucketPullRequestApi.ts | 19 ++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index f57bb67a4..4120cf55e 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -867,6 +867,46 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect( + "reads a removed permissions endpoint as granted rather than failing the merge on it", + () => + Effect.gen(function* () { + // Bitbucket retired /user/permissions/repositories under CHANGE-2770: every account now + // gets HTTP 410 here, whatever it may do. + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 410, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + assert.isTrue(yield* api.getRepositoryPermission({ repository: "acme/web" })); + }), + ); + + it.effect("still fails the permission read on a failure that is not the removed endpoint", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue( + Effect.fail( + new BitbucketApi.BitbucketResponseError({ + operation: "request", + status: 401, + responseBodyLength: 0, + }), + ), + ); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + const error = yield* Effect.flip(api.getRepositoryPermission({ repository: "acme/web" })); + + assert.strictEqual(error._tag, "BitbucketResponseError"); + }), + ); + it.effect("reads the workspace's people and marks whoever is already a reviewer", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a20d4aaaa..a2c57bfc5 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -107,6 +107,16 @@ export type BitbucketPullRequestApiError = | BitbucketRepositoryUnsupportedError | BitbucketDiffCommitError; +/** + * `/user/permissions/repositories` answering CHANGE-2770's removal notice rather than a + * permission — Bitbucket sends this for every account now, not only ones it would have refused. + */ +function isRepositoryPermissionRemovedError( + error: BitbucketPullRequestApiError, +): error is BitbucketApi.BitbucketResponseError { + return error._tag === "BitbucketResponseError" && error.status === 410; +} + /** * Bitbucket's own ceiling. Asking for more does not fail — it answers with an empty page and no * error at all, so this is a number to respect rather than to push against. @@ -553,6 +563,13 @@ export const make = Effect.gen(function* () { // Nothing on the repository, the pull request or the workspace states what the credentials // may do, so this endpoint is the one request Bitbucket makes unavoidable. It is asked // alongside the reads the detail was already making, so it costs no round trip of its own. + // + // Bitbucket permanently removed this endpoint (CHANGE-2770): every account now gets HTTP 410 + // in place of an answer, whatever it may do. That is the deprecated-endpoint signal, not a + // permission being refused, so it is read the same way an unreachable read already is + // elsewhere — as a permission that could not be learned, which grants rather than blocks, and + // leaves the actual merge or write to say why if the account may not do it. Any other failure + // (a bad token, a network fault, an unreadable body) still fails as it did before. getRepositoryPermission: (input) => withRepository(input.repository, () => readPage({ @@ -562,7 +579,7 @@ export const make = Effect.gen(function* () { )}`, decode: decodeRepositoryPermissionJson, }), - ), + ).pipe(Effect.catchIf(isRepositoryPermissionRemovedError, () => Effect.succeed(true))), getPullRequestDiff: (input) => input.commit !== undefined && !isCommitSha(input.commit) From 1de4a1a268fd944610c35f29a1437a6dd63005fb Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:43:42 +0200 Subject: [PATCH 14/99] fix(ssh): let cold remote servers finish starting (#6168) (cherry picked from commit 672216d7e152241213a8757892f281e1f4434e8a) --- packages/ssh/src/tunnel.test.ts | 34 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 76b8ecccb..be17b8ffa 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -45,6 +45,16 @@ const makeSuccessfulProcess = (stdout: string) => { }); }; +const makeDelayedSuccessfulProcess = (stdout: string, delayMs: number) => { + const process = makeSuccessfulProcess(stdout); + return { + ...process, + exitCode: Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + }; +}; + const makeRunningProcess = (onKill: () => void) => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; return ChildProcessSpawner.makeHandle({ @@ -174,6 +184,7 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); + assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), @@ -235,6 +246,29 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("allows cold remote launches to exceed the default SSH command timeout", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawner = ChildProcessSpawner.make(() => + Effect.succeed(makeDelayedSuccessfulProcess('{"remotePort":3774}\n', 75_000)), + ); + const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); + const processLayer = Layer.mergeAll(NodeServices.layer, spawnerLayer, TestClock.layer()); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild(launchOrReuseRemoteServer(target)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.seconds(75)); + + const result = yield* Fiber.join(fiber); + assert.equal(result.remotePort, 3774); + }).pipe(Effect.provide(processLayer)); + }); + it("allows the remote port picker to run without a state file path", () => { assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 179d1fcb5..a1611c577 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -54,7 +54,8 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; -const REMOTE_READY_TIMEOUT_MS = 15_000; +const REMOTE_READY_TIMEOUT_MS = 60_000; +const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; export interface RemoteT3RunnerOptions { @@ -705,6 +706,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo const result = yield* runSshCommand(target, { remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], stdin: buildRemoteLaunchScript(runner), + timeoutMs: REMOTE_LAUNCH_TIMEOUT_MS, ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), From aef3745a5ce68c1a135bf81d68263ccac4b7f59f Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:43:50 +0800 Subject: [PATCH 15/99] fix(web): preserve Claude insight line breaks (#4344) (cherry picked from commit 1e87029261f9b81061a2a7420849b9eeaf1a2ebe) --- .../components/chat/MessagesTimeline.logic.test.ts | 12 ++++++++++++ .../src/components/chat/MessagesTimeline.logic.ts | 4 ++++ apps/web/src/components/chat/MessagesTimeline.tsx | 2 ++ 3 files changed, 18 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index f5ec7e83f..768ced08c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -5,8 +5,20 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + shouldPreserveAssistantLineBreaks, } from "./MessagesTimeline.logic"; +describe("shouldPreserveAssistantLineBreaks", () => { + it("preserves Claude insight formatting without changing regular markdown", () => { + expect( + shouldPreserveAssistantLineBreaks( + "★ Insight ─────────────────\\nFirst observation\\nSecond observation\\n─────────────────", + ), + ).toBe(true); + expect(shouldPreserveAssistantLineBreaks("A normal\\nmarkdown paragraph")).toBe(false); + }); +}); + describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { const result = computeMessageDurationStart([ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c320a0c7b..723d41a40 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -54,6 +54,10 @@ export function resolveTimelineIsAtEnd( return contentLength - scroll - scrollLength - endInset <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX; } +export function shouldPreserveAssistantLineBreaks(text: string): boolean { + return /^★ Insight(?:\s|─)/mu.test(text); +} + export function resolveTimelineMinimapHeightStyle(itemCount: number): string { const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d1fda890..6ff6450e6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -82,6 +82,7 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, + shouldPreserveAssistantLineBreaks, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -1126,6 +1127,7 @@ function AssistantTimelineRow({ row }: { row: Extract Date: Sat, 15 Aug 2026 03:43:58 -0700 Subject: [PATCH 16/99] feat(web): accept file drops across the chat workspace (#6636) (cherry picked from commit a6ac27e7fd965f26ee0c61946e056f0fd889818c) --- apps/web/src/components/ChatView.tsx | 43 +++++++++- apps/web/src/components/chat/ChatComposer.tsx | 49 ++---------- .../components/chat/workspaceFileDrop.test.ts | 78 +++++++++++++++++++ .../src/components/chat/workspaceFileDrop.ts | 54 +++++++++++++ 4 files changed, 181 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/components/chat/workspaceFileDrop.test.ts create mode 100644 apps/web/src/components/chat/workspaceFileDrop.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 12e8d8c97..8a735d315 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -151,6 +151,7 @@ import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -180,6 +181,7 @@ import { CheckCircle2Icon, ChevronDownIcon, GitBranchIcon, + PaperclipIcon, WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; @@ -1437,6 +1439,7 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); @@ -1461,6 +1464,18 @@ function ChatViewContent(props: ChatViewProps) { useState(null); const sessionInteractionSubmissionLockRef = useRef(null); const sessionInteractionSubmissionAttemptRef = useRef(0); + + useEffect(() => { + setIsWorkspaceFileDragActive(false); + }, [draftId, routeThreadKey]); + + useEffect(() => { + if (!isWorkspaceFileDragActive) return; + const clearWorkspaceFileDrag = () => setIsWorkspaceFileDragActive(false); + window.addEventListener("dragend", clearWorkspaceFileDrag); + return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); + }, [isWorkspaceFileDragActive]); + const [pendingUserInputAnswersByRequestId, setPendingUserInputAnswersByRequestId] = useState< Record> >({}); @@ -7113,6 +7128,11 @@ function ChatViewContent(props: ChatViewProps) { ) : null ) : null; + const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ + setDragActive: setIsWorkspaceFileDragActive, + addFiles: (files) => composerRef.current?.addDroppedFiles(files), + }); + return (
{rightPanelOpen && !shouldUseRightPanelSheet ? panelLayoutControls : null} @@ -7182,7 +7202,28 @@ function ChatViewContent(props: ChatViewProps) { {/* Main content area with optional plan sidebar */}
{/* Chat column */} -
+
+ {isWorkspaceFileDragActive ? ( +
+
+
+
+ ) : null} {/* Provider status overlays the timeline without changing its content height. */}
void; focusAt: (cursor: number) => void; + addDroppedFiles: (files: File[]) => void; insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; openModelPicker: () => void; toggleModelPicker: () => void; @@ -1723,7 +1724,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const dragDepthRef = useRef(0); const stashPulseKeyRef = useRef(0); const stashPulseTimeoutRef = useRef(null); /** @@ -2150,7 +2150,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerHighlightedItemId(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); - dragDepthRef.current = 0; setIsDragOverComposer(false); }, [draftId, activeThreadId, promptRef]); @@ -3130,41 +3129,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) void addComposerImages(imageFiles); }; - const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragOverComposer(true); - }; - - const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - setIsDragOverComposer(true); - }; - - const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - if (dragDepthRef.current === 0) { - setIsDragOverComposer(false); - } - }; - - const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragOverComposer(false); - const files = Array.from(event.dataTransfer.files); - void addComposerImages(files); - focusComposer(); - }; - const insertComposerTextAtEnd = ( text: string, options?: { ensureLeadingBoundary?: boolean }, @@ -3218,7 +3182,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (!isDragOverComposer) return; const onWindowDragEnd = () => { - dragDepthRef.current = 0; setIsDragOverComposer(false); }; window.addEventListener("dragend", onWindowDragEnd); @@ -3287,6 +3250,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, + addDroppedFiles: (files: File[]) => { + void addComposerImages(files); + focusComposer(); + }, insertTextAtEnd: insertComposerTextAtEnd, openModelPicker: () => { setIsComposerModelPickerOpen(true); @@ -3381,6 +3348,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }), [ activeThread, + addComposerImages, composerDraftTarget, composerCursor, composerTerminalContexts, @@ -3391,6 +3359,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, + focusComposer, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -3435,10 +3404,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "group rounded-[22px] p-px transition-colors duration-200", composerProviderState.composerFrameClassName, )} - onDragEnter={onComposerDragEnter} - onDragOver={onComposerDragOver} - onDragLeave={onComposerDragLeave} - onDrop={onComposerDrop} onDragEnterCapture={composerMentionDragHandlers.onDragEnter} onDragOverCapture={composerMentionDragHandlers.onDragOver} onDragLeaveCapture={onComposerMentionDragLeaveCapture} diff --git a/apps/web/src/components/chat/workspaceFileDrop.test.ts b/apps/web/src/components/chat/workspaceFileDrop.test.ts new file mode 100644 index 000000000..ec5d074a3 --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { + makeWorkspaceFileDropHandlers, + type WorkspaceFileDragEvent, + type WorkspaceFileDropHost, +} from "./workspaceFileDrop"; + +function makeDragEvent(options?: { + types?: string[]; + files?: File[]; + movedWithinTarget?: boolean; +}) { + const preventDefault = vi.fn(); + const event = { + dataTransfer: { + types: options?.types ?? ["Files"], + files: options?.files ?? [], + dropEffect: "none", + }, + relatedTarget: options?.movedWithinTarget ? ({} as EventTarget) : null, + currentTarget: { + contains: () => options?.movedWithinTarget ?? false, + }, + preventDefault, + } satisfies WorkspaceFileDragEvent; + return { event, preventDefault }; +} + +function makeHost() { + const setDragActive = vi.fn(); + const addFiles = vi.fn(); + const host = { setDragActive, addFiles } satisfies WorkspaceFileDropHost; + return { host, setDragActive, addFiles }; +} + +describe("makeWorkspaceFileDropHandlers", () => { + it("activates the target for an external file drag", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent(); + + makeWorkspaceFileDropHandlers(host).onDragEnter(event); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(setDragActive).toHaveBeenCalledWith(true); + }); + + it("ignores non-file drags", () => { + const { host, setDragActive } = makeHost(); + const { event, preventDefault } = makeDragEvent({ types: ["text/plain"] }); + + makeWorkspaceFileDropHandlers(host).onDragOver(event); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("does not flicker when the drag moves between children", () => { + const { host, setDragActive } = makeHost(); + const { event } = makeDragEvent({ movedWithinTarget: true }); + + const handlers = makeWorkspaceFileDropHandlers(host); + handlers.onDragEnter(event); + handlers.onDragLeave(event); + + expect(setDragActive).not.toHaveBeenCalled(); + }); + + it("forwards dropped files and clears the active state", () => { + const file = new File(["contents"], "example.txt", { type: "text/plain" }); + const { host, setDragActive, addFiles } = makeHost(); + const { event } = makeDragEvent({ files: [file] }); + + makeWorkspaceFileDropHandlers(host).onDrop(event); + + expect(setDragActive).toHaveBeenCalledWith(false); + expect(addFiles).toHaveBeenCalledWith([file]); + }); +}); diff --git a/apps/web/src/components/chat/workspaceFileDrop.ts b/apps/web/src/components/chat/workspaceFileDrop.ts new file mode 100644 index 000000000..132a8051e --- /dev/null +++ b/apps/web/src/components/chat/workspaceFileDrop.ts @@ -0,0 +1,54 @@ +export interface WorkspaceFileDragEvent { + readonly dataTransfer: { + readonly types: ReadonlyArray; + readonly files: Iterable; + dropEffect: string; + }; + readonly relatedTarget: EventTarget | null; + readonly currentTarget: { + contains(target: Node | null): boolean; + }; + preventDefault(): void; +} + +export interface WorkspaceFileDropHost { + setDragActive(active: boolean): void; + addFiles(files: File[]): void; +} + +function isFileDrag(event: WorkspaceFileDragEvent): boolean { + return event.dataTransfer.types.includes("Files"); +} + +function movedWithinDropTarget(event: WorkspaceFileDragEvent): boolean { + return event.relatedTarget !== null && event.currentTarget.contains(event.relatedTarget as Node); +} + +export function makeWorkspaceFileDropHandlers(host: WorkspaceFileDropHost) { + return { + onDragEnter(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(true); + }, + onDragOver(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + host.setDragActive(true); + }, + onDragLeave(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (movedWithinDropTarget(event)) return; + host.setDragActive(false); + }, + onDrop(event: WorkspaceFileDragEvent) { + if (!isFileDrag(event)) return; + event.preventDefault(); + host.setDragActive(false); + host.addFiles(Array.from(event.dataTransfer.files)); + }, + }; +} From 1c0ecdb79bafa3e58cabc0f2e4181bec19790b09 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:01 +0200 Subject: [PATCH 17/99] fix(web): widen ordered-list marker gutter for 3+ digit item numbers (#6527) (cherry picked from commit eaa6c4712fe11f0396e549b1873f163dc202d229) --- apps/web/src/components/ChatMarkdown.test.tsx | 36 +++++++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 29 +++++++++++++++ apps/web/src/index.css | 14 ++++++-- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.test.tsx diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx new file mode 100644 index 000000000..9499ee5a6 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { orderedListGutterStyle } from "./ChatMarkdown"; + +describe("orderedListGutterStyle", () => { + it("leaves the default gutter alone for single-digit lists", () => { + expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for two-digit lists", () => { + expect(orderedListGutterStyle(99, undefined)).toBeUndefined(); + }); + + it("leaves the default gutter alone for a two-digit list that starts above 1", () => { + // start=50 + 49 items => last marker is "98", still two digits. + expect(orderedListGutterStyle(49, 50)).toBeUndefined(); + }); + + it("widens the gutter once the last marker reaches three digits", () => { + // item 100 is the bug from #6512: a 100-item list starting at 1. + expect(orderedListGutterStyle(100, undefined)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("accounts for a non-default start attribute", () => { + // start=95 + 9 items => last marker is "103", three digits. + expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); + }); + + it("scales further for four-digit markers", () => { + expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); + }); + + it("treats a missing/zero item count as a single item", () => { + expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 53b043f3a..294a9e22a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -146,6 +146,26 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb if (!match?.[1]) return null; return listItemStart + firstLine.indexOf(match[1]); } + +/** + * The default `1.25rem` marker gutter (`.chat-markdown ol`) fits two-digit + * decimal markers. Once a list's last item reaches three digits (item 100+), + * `list-style-position: outside` paints the marker wider than that gutter and + * the leading digit gets clipped by the item's own overflow. Rather than + * widening the gutter for every list, only lists whose last marker is 3+ + * digits get a wider `--list-gutter`, sized to that marker's digit count. + */ +export function orderedListGutterStyle( + itemCount: number, + start: number | undefined, +): { "--list-gutter": string } | undefined { + const firstNumber = typeof start === "number" && Number.isFinite(start) ? start : 1; + const lastNumber = firstNumber + Math.max(itemCount - 1, 0); + const digits = String(Math.abs(lastNumber)).length; + if (digits <= 2) return undefined; + return { "--list-gutter": `${digits + 1}ch` }; +} + const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -1506,6 +1526,15 @@ function ChatMarkdown({
); }, + ol({ node, start, style, ...props }) { + const itemCount = + node?.children?.filter((child) => child.type === "element" && child.tagName === "li") + .length ?? 0; + const gutterStyle = orderedListGutterStyle(itemCount, start); + return ( +
    + ); + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 49d27983c..d93615e11 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1683,12 +1683,22 @@ code { } .chat-markdown ul { + /* Reset for nested uls under a widened ol — --list-gutter is an inherited + custom property, so without this a task-list under a 3+ digit ordered + list would inherit the outer gutter instead of its own default. */ + --list-gutter: 1.25rem; padding-left: 1.25rem; list-style-type: disc; } +/* --list-gutter defaults to the same 1.25rem as .chat-markdown ul, but + ChatMarkdown's `ol` renderer widens it (via inline style) for lists whose + last marker is 3+ digits, so item 100+ isn't clipped by list-style-position: + outside painting the marker past the padding box. Reset it here too so a + nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { - padding-left: 1.25rem; + --list-gutter: 1.25rem; + padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1718,7 +1728,7 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em -1.25rem; + margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); vertical-align: middle; } From 880d7474d5f971e84d479897fe7c0ae41b13c605 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:03 +0200 Subject: [PATCH 18/99] fix(server): bound thread activity hydration (#6153) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> (cherry picked from commit 71c6f8248775066ebaf4bfc6680d3e2acb4bb2d1) --- .../Layers/ProjectionSnapshotQuery.test.ts | 124 +++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 203 +++++++++++++++--- 2 files changed, 299 insertions(+), 28 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d9e8a3d65..a1495c6eb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2312,6 +2312,130 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("bounds activity hydration and preserves unresolved requests", () => + Effect.gen(function* () { + yield* seedFanOutThread(); + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_activities`; + yield* sql` + WITH RECURSIVE activity_rows(sequence) AS ( + SELECT 1 + UNION ALL + SELECT sequence + 1 FROM activity_rows WHERE sequence < 501 + ) + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + SELECT + printf('activity-%04d', sequence), + 'thread-w', + 'turn-5', + 'tool', + 'tool.completed', + 'ran tool', + printf('{"sequence":%d}', sequence), + sequence, + '2026-03-01T00:04:00.000Z' + FROM activity_rows + `; + + const fullDetail = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(fullDetail._tag, "Some"); + if (fullDetail._tag === "Some") { + assert.equal(fullDetail.value.activities.length, 500); + assert.equal(fullDetail.value.activities[0]?.id, asEventId("activity-0002")); + assert.equal(fullDetail.value.activities.at(-1)?.id, asEventId("activity-0501")); + } + + const windowedDetail = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowedDetail._tag, "Some"); + if (windowedDetail._tag === "Some") { + assert.equal(windowedDetail.value.thread.activities.length, 500); + assert.equal(windowedDetail.value.thread.activities[0]?.id, asEventId("activity-0002")); + assert.equal(windowedDetail.value.thread.activities.at(-1)?.id, asEventId("activity-0501")); + } + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'approval-old', 'thread-w', NULL, 'approval', 'approval.requested', + 'Approve old command', '{"requestId":"approval-1"}', NULL, + '2026-03-01T00:00:01.000Z' + ), + ( + 'user-input-old', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Answer old question', '{"requestId":"input-1"}', NULL, + '2026-03-01T00:00:02.000Z' + ), + ( + 'user-input-closed', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:03.000Z' + ), + ( + 'user-input-closed-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Closed question', '{"requestId":"input-closed"}', NULL, + '2026-03-01T00:00:04.000Z' + ), + ( + 'user-input-tied-z-request', 'thread-w', NULL, 'approval', 'user-input.requested', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ), + ( + 'user-input-tied-a-resolution', 'thread-w', NULL, 'info', 'user-input.resolved', + 'Tied open question', '{"requestId":"input-tied-open"}', NULL, + '2026-03-01T00:00:05.000Z' + ) + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, turn_id, status, decision, created_at, resolved_at + ) + VALUES ( + 'approval-1', 'thread-w', NULL, 'pending', NULL, + '2026-03-01T00:00:01.000Z', NULL + ) + `; + yield* sql` + UPDATE projection_threads + SET pending_approval_count = 1, pending_user_input_count = 1 + WHERE thread_id = 'thread-w' + `; + + const detailWithPinnedRequests = yield* snapshotQuery.getThreadDetailById(threadW); + assert.equal(detailWithPinnedRequests._tag, "Some"); + if (detailWithPinnedRequests._tag === "Some") { + const ids = detailWithPinnedRequests.value.activities.map((activity) => activity.id); + assert.equal(detailWithPinnedRequests.value.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + + const windowWithPinnedRequests = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 2, + }); + assert.equal(windowWithPinnedRequests._tag, "Some"); + if (windowWithPinnedRequests._tag === "Some") { + const ids = windowWithPinnedRequests.value.thread.activities.map((activity) => activity.id); + assert.equal(windowWithPinnedRequests.value.thread.activities.length, 503); + assert.equal(ids.includes(asEventId("approval-old")), true); + assert.equal(ids.includes(asEventId("user-input-old")), true); + assert.equal(ids.includes(asEventId("user-input-closed")), false); + assert.equal(ids.includes(asEventId("user-input-tied-z-request")), true); + } + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c08ad98d1..f1e2dbc70 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -69,6 +69,10 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +// Keep detail reads consistent with the in-memory projector's retained +// activity window. Applying the limit in SQL avoids decoding an unbounded +// payload_json set before the projector can enforce that invariant. +const THREAD_DETAIL_ACTIVITY_LIMIT = 500; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -1036,8 +1040,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -1256,6 +1277,95 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + // Blocking request payloads must remain available even if they predate the + // recent activity window. Each CTE returns at most one unresolved row per + // request, so the merge below stays bounded by actionable work. + const listPinnedThreadActivityRowsByThread = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId }) => + sql` + WITH pending_approval_requests AS ( + SELECT request_id, thread_id + FROM projection_pending_approvals + WHERE thread_id = ${threadId} + AND status = 'pending' + ), + pending_approval_activities AS ( + SELECT + activity.activity_id, + ROW_NUMBER() OVER ( + PARTITION BY pending.request_id + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_approval_requests AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND activity.kind = 'approval.requested' + AND json_extract(activity.payload_json, '$.requestId') = pending.request_id + ), + pending_user_input_thread AS ( + SELECT thread_id + FROM projection_threads + WHERE thread_id = ${threadId} + AND pending_user_input_count > 0 + ), + user_input_lifecycle AS ( + SELECT + activity.activity_id, + activity.kind, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.created_at DESC, activity.activity_id DESC + ) AS request_order + FROM pending_user_input_thread AS pending + CROSS JOIN projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND ( + activity.kind IN ('user-input.requested', 'user-input.resolved') + OR ( + activity.kind = 'provider.user-input.respond.failed' + AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%stale pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending codex user input request%' + ) + ) + ) + AND json_extract(activity.payload_json, '$.requestId') IS NOT NULL + ), + pinned_activity_ids AS ( + SELECT activity_id + FROM pending_approval_activities + WHERE request_order = 1 + UNION ALL + SELECT activity_id + FROM user_input_lifecycle + WHERE request_order = 1 + AND kind = 'user-input.requested' + ) + SELECT + activity.activity_id AS "activityId", + activity.thread_id AS "threadId", + activity.turn_id AS "turnId", + activity.tone, + activity.kind, + activity.summary, + activity.payload_json AS "payload", + activity.sequence, + activity.created_at AS "createdAt" + FROM pinned_activity_ids AS pinned + INNER JOIN projection_thread_activities AS activity + ON activity.activity_id = pinned.activity_id + ORDER BY activity.created_at ASC, activity.activity_id ASC + `, + }); + const listThreadActivityRowsByThreadWindow = SqlSchema.findAll({ Request: ThreadTurnRangeLookupInput, Result: ProjectionThreadActivityDbRowSchema, @@ -1271,34 +1381,51 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} - AND ( - turn_id IN ( - SELECT turn_id FROM projection_turns - WHERE thread_id = ${threadId} - AND turn_id IS NOT NULL - AND ( - requested_at > ${minAnchorAt} - OR ( - requested_at = ${minAnchorAt} - AND turn_id >= ${minTurnKey} + FROM ( + SELECT + activity_id, + thread_id, + turn_id, + tone, + kind, + summary, + payload_json, + sequence, + created_at + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND ( + turn_id IN ( + SELECT turn_id FROM projection_turns + WHERE thread_id = ${threadId} + AND turn_id IS NOT NULL + AND ( + requested_at > ${minAnchorAt} + OR ( + requested_at = ${minAnchorAt} + AND turn_id >= ${minTurnKey} + ) ) - ) - AND ( - requested_at < ${beforeAnchorAt} - OR ( - requested_at = ${beforeAnchorAt} - AND turn_id < ${beforeTurnKey} + AND ( + requested_at < ${beforeAnchorAt} + OR ( + requested_at = ${beforeAnchorAt} + AND turn_id < ${beforeTurnKey} + ) ) - ) - ) - OR ( - turn_id IS NULL - AND created_at >= ${minAnchorAt} - AND created_at < ${beforeAnchorAt} + ) + OR ( + turn_id IS NULL + AND created_at >= ${minAnchorAt} + AND created_at < ${beforeAnchorAt} + ) ) - ) + ORDER BY + sequence DESC, + created_at DESC, + activity_id DESC + LIMIT ${THREAD_DETAIL_ACTIVITY_LIMIT} + ) AS recent_activities ORDER BY sequence ASC, created_at ASC, @@ -2408,6 +2535,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { messageRows, proposedPlanRows, activityRows, + pinnedActivityRows, checkpointRows, latestTurnRow, sessionRow, @@ -2450,6 +2578,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listPinnedThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPinnedActivities:decodeRows", + ), + ), + ), listCheckpointRowsByThread({ threadId }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -2480,6 +2616,17 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } + const selectedActivityRows = [ + ...new Map( + [...activityRows, ...pinnedActivityRows].map((row) => [row.activityId, row] as const), + ).values(), + ].toSorted( + (left, right) => + (left.sequence ?? -1) - (right.sequence ?? -1) || + left.createdAt.localeCompare(right.createdAt) || + left.activityId.localeCompare(right.activityId), + ); + const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2518,7 +2665,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return message; }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: activityRows.map((row) => { + activities: selectedActivityRows.map((row) => { const activity = { id: row.activityId, tone: row.tone, From 970a715205d98ef5ff88ae9110f0acc0dbf04a25 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:44:19 +0200 Subject: [PATCH 19/99] fix(web): restore the Archive action in the default sidebar thread menu (#6526) (cherry picked from commit 48cba7d93c8c63508f31cce2544d480ace86f929) --- apps/web/src/components/Sidebar.tsx | 34 +++++++++++++++++++ .../components/threadActionMenu.logic.test.ts | 27 ++++++++++++++- .../src/components/threadActionMenu.logic.ts | 9 +++++ apps/web/src/hooks/useThreadActionMenu.ts | 26 ++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43001a85e..530b41c65 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1624,6 +1624,7 @@ export default function Sidebar() { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -1635,6 +1636,7 @@ export default function Sidebar() { pinThread, unpinThread, reorderPinnedThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -2992,6 +2994,8 @@ export default function Sidebar() { isSnoozed, canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), isRegeneratingTitle, + isRunning: + thread.session?.status === "running" && thread.session.activeTurnId != null, supports: { settlement: supportsSettlement, snooze: supportsSnooze, @@ -3095,6 +3099,34 @@ export default function Sidebar() { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: didArchive + ? "Thread archived, but navigation failed" + : "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + return; + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3128,12 +3160,14 @@ export default function Sidebar() { })(); }, [ + archiveThread, attemptPin, attemptSettle, attemptSnooze, attemptUnpin, attemptUnsettle, attemptUnsnooze, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 93dc653e7..c839ddc3b 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -9,6 +9,7 @@ const baseState: ThreadActionMenuState = { isSnoozed: false, canSnoozeNow: true, isRegeneratingTitle: false, + isRunning: false, supports: { settlement: true, snooze: true, pinning: true, titleRegeneration: true }, snoozePresets: [ { id: "hour", label: "In 1 hour", whenLabel: "3:00 PM", snoozedUntil: "2026-08-07T15:00:00Z" }, @@ -26,7 +27,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]); }); it("includes branch items only for threads with a branch", () => { @@ -63,4 +64,28 @@ describe("buildThreadActionMenuItems", () => { const items = buildThreadActionMenuItems({ ...baseState, branch: "main" }); expect(items.at(-1)).toMatchObject({ id: "delete", destructive: true }); }); + + it("offers archive as a non-destructive action right before delete", () => { + const items = buildThreadActionMenuItems(baseState); + const archiveItem = items.at(-2); + expect(archiveItem?.id).toBe("archive"); + expect(archiveItem?.destructive).toBeFalsy(); + expect(items.at(-1)?.id).toBe("delete"); + }); + + it("keeps archive available even when the environment lacks every other capability", () => { + expect( + ids({ + ...baseState, + supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, + }), + ).toContain("archive"); + }); + + it("disables archive while the thread is running", () => { + const archiveItem = buildThreadActionMenuItems({ ...baseState, isRunning: true }).find( + (item) => item.id === "archive", + ); + expect(archiveItem?.disabled).toBe(true); + }); }); diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index ef4b38dcd..44c2e907c 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -21,6 +21,7 @@ export type ThreadActionMenuId = | "copy-path" | "copy-branch" | "copy-thread-id" + | "archive" | "delete"; export interface ThreadActionMenuState { @@ -30,6 +31,8 @@ export interface ThreadActionMenuState { readonly isSnoozed: boolean; readonly canSnoozeNow: boolean; readonly isRegeneratingTitle: boolean; + /** Archive rejects a thread with an active turn, so disable it here rather than let the action fail. */ + readonly isRunning: boolean; readonly supports: { readonly settlement: boolean; readonly snooze: boolean; @@ -102,6 +105,12 @@ export function buildThreadActionMenuItems( { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, + // Archive removes the thread from the sidebar while keeping its + // conversation under Settings > Archived threads — distinct from Settle + // (stays visible in the Settled shelf) and Delete (clears history for + // good), so it sits beside Delete without borrowing its destructive + // styling. + { id: "archive", label: "Archive thread", disabled: state.isRunning }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index d7ca23051..4a25df47b 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -72,6 +72,7 @@ export function useThreadActionMenu(input: { unsnoozeThread, pinThread, unpinThread, + archiveThread, deleteThread, } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { @@ -82,6 +83,7 @@ export function useThreadActionMenu(input: { const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((s) => s.sidebarAutoSettleOnMerge); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const timestampFormat = useClientSettings((s) => s.timestampFormat); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { @@ -139,6 +141,7 @@ export function useThreadActionMenu(input: { isSnoozed: supports.snooze && effectiveSnoozed(thread, { now: now.toISOString() }), canSnoozeNow: canSnooze(thread, { now: now.toISOString() }), isRegeneratingTitle, + isRunning: thread.session?.status === "running" && thread.session.activeTurnId != null, supports, snoozePresets, }); @@ -253,6 +256,27 @@ export function useThreadActionMenu(input: { case "copy-thread-id": copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; + case "archive": { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${thread.title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failureToast( + didArchive ? "Thread archived, but navigation failed" : "Failed to archive thread", + squashAtomCommandFailure(result), + ); + } + return; + } case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -285,9 +309,11 @@ export function useThreadActionMenu(input: { })(); }, [ + archiveThread, autoSettleAfterDays, autoSettleOnMerge, changeRequestState, + confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, From 3c2012abb754b47396b645da364226c9e5855cd2 Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 12:44:22 +0200 Subject: [PATCH 20/99] fix(web): open diff files from nested projects (#6174) (cherry picked from commit 9f26656cb958853f90f7215387d604c098937db8) --- apps/web/src/components/DiffPanel.tsx | 6 +- apps/web/src/diffFileActions.test.ts | 75 ++++++++++++++++++++++++- apps/web/src/diffFileActions.ts | 79 ++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b929d05a7..66f0a4e11 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -134,6 +134,9 @@ export default function DiffPanel({ : null, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot; + const activeRepositoryRoot = activeThread?.worktreePath + ? undefined + : activeProject?.repositoryIdentity?.rootPath; const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); @@ -443,6 +446,7 @@ export default function DiffPanel({ threadRef: routeThreadRef, filePath, activeCwd, + repositoryRoot: activeRepositoryRoot, openInEditor: (targetPath) => { void (async () => { const result = await openInPreferredEditor(targetPath); @@ -462,7 +466,7 @@ export default function DiffPanel({ }, }); }, - [activeCwd, openInPreferredEditor, routeThreadRef], + [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef], ); const toggleDiffFileCollapsed = useCallback( (fileKey: string) => { diff --git a/apps/web/src/diffFileActions.test.ts b/apps/web/src/diffFileActions.test.ts index 9c358ab1d..c5d3571a9 100644 --- a/apps/web/src/diffFileActions.test.ts +++ b/apps/web/src/diffFileActions.test.ts @@ -2,7 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { openDiffFilePrimaryAction } from "./diffFileActions"; +import { openDiffFilePrimaryAction, resolveDiffPathForWorkspace } from "./diffFileActions"; import { selectThreadRightPanelState, useRightPanelStore } from "./rightPanelStore"; const THREAD_REF = scopeThreadRef( @@ -48,4 +48,77 @@ describe("openDiffFilePrimaryAction", () => { "/repo/project/apps/web/src/components/DiffPanel.tsx", ); }); + + it("opens repository-relative diff files from a nested project", () => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath: "frontend/Dockerfile", + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ + isOpen: true, + activeSurfaceId: "file:Dockerfile", + }); + expect(openInEditor).not.toHaveBeenCalled(); + }); + + it("preserves repository-relative paths in a separate worktree", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/Dockerfile", + workspaceRoot: "/worktrees/feature", + repositoryRoot: "/repo", + }), + ).toBe("frontend/Dockerfile"); + }); + + it("handles Windows roots and mixed diff separators", () => { + expect( + resolveDiffPathForWorkspace({ + filePath: "Frontend/src\\index.ts", + workspaceRoot: "C:\\repo\\frontend", + repositoryRoot: "C:\\repo", + }), + ).toBe("src/index.ts"); + }); + + it.each([ + { workspaceRoot: "/frontend", repositoryRoot: "/" }, + { workspaceRoot: "C:\\frontend", repositoryRoot: "C:\\" }, + ])("handles filesystem roots: $repositoryRoot", ({ workspaceRoot, repositoryRoot }) => { + expect( + resolveDiffPathForWorkspace({ + filePath: "frontend/index.ts", + workspaceRoot, + repositoryRoot, + }), + ).toBe("index.ts"); + }); + + it.each(["backend/server.ts", "frontend2/app.ts", "frontend/../secret.ts", "C:secret.ts"])( + "does not open an out-of-project diff path: %s", + (filePath) => { + const openInEditor = vi.fn(); + + openDiffFilePrimaryAction({ + threadRef: THREAD_REF, + filePath, + activeCwd: "/repo/frontend", + repositoryRoot: "/repo", + openInEditor, + }); + + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, THREAD_REF), + ).toMatchObject({ isOpen: false }); + expect(openInEditor).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts index 335ad21fc..3ac22c28c 100644 --- a/apps/web/src/diffFileActions.ts +++ b/apps/web/src/diffFileActions.ts @@ -1,4 +1,5 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import { isWindowsAbsolutePath, normalizeProjectPathForComparison } from "@t3tools/shared/path"; import { useRightPanelStore } from "./rightPanelStore"; import { resolvePathLinkTarget } from "./terminal-links"; @@ -7,19 +8,93 @@ interface OpenDiffFilePrimaryActionInput { readonly threadRef: ScopedThreadRef | null; readonly filePath: string; readonly activeCwd: string | undefined; + readonly repositoryRoot?: string | undefined; readonly openInEditor: (targetPath: string) => void; } +function normalizedRelativePathSegments(filePath: string): ReadonlyArray | null { + if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) { + return null; + } + + const segments = filePath + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0 || segments.includes("..")) return null; + return segments; +} + +function repositoryRelativeWorkspaceSegments( + workspaceRoot: string | undefined, + repositoryRoot: string | undefined, +): ReadonlyArray | null { + if (!workspaceRoot || !repositoryRoot) return null; + + const normalizedWorkspaceRoot = normalizeProjectPathForComparison(workspaceRoot); + const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot); + if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return []; + + const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/"; + const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator) + ? normalizedRepositoryRoot + : `${normalizedRepositoryRoot}${separator}`; + if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null; + + return normalizedWorkspaceRoot + .slice(repositoryPrefix.length) + .split(/[\\/]+/) + .filter(Boolean); +} + +export function resolveDiffPathForWorkspace(input: { + readonly filePath: string; + readonly workspaceRoot: string | undefined; + readonly repositoryRoot: string | undefined; +}): string | null { + const fileSegments = normalizedRelativePathSegments(input.filePath); + if (!fileSegments) return null; + + const workspaceSegments = repositoryRelativeWorkspaceSegments( + input.workspaceRoot, + input.repositoryRoot, + ); + if (!workspaceSegments || workspaceSegments.length === 0) { + return fileSegments.join("/"); + } + + const caseInsensitive = input.repositoryRoot + ? isWindowsAbsolutePath(input.repositoryRoot) + : false; + const belongsToWorkspace = workspaceSegments.every((segment, index) => { + const candidate = fileSegments[index]; + if (candidate === undefined) return false; + return caseInsensitive ? candidate.toLowerCase() === segment : candidate === segment; + }); + if (!belongsToWorkspace) return null; + + const relativeSegments = fileSegments.slice(workspaceSegments.length); + return relativeSegments.length > 0 ? relativeSegments.join("/") : null; +} + export function openDiffFilePrimaryAction({ threadRef, filePath, activeCwd, + repositoryRoot, openInEditor, }: OpenDiffFilePrimaryActionInput): void { + const workspaceFilePath = resolveDiffPathForWorkspace({ + filePath, + workspaceRoot: activeCwd, + repositoryRoot, + }); + if (!workspaceFilePath) return; + if (threadRef) { - useRightPanelStore.getState().openFile(threadRef, filePath); + useRightPanelStore.getState().openFile(threadRef, workspaceFilePath); return; } - openInEditor(activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath); + openInEditor(activeCwd ? resolvePathLinkTarget(workspaceFilePath, activeCwd) : workspaceFilePath); } From 3841ce694b6a888325c989b057fc0dd11f5b9406 Mon Sep 17 00:00:00 2001 From: mohamedmastouri-hue Date: Sat, 15 Aug 2026 11:44:30 +0100 Subject: [PATCH 21/99] fix(mobile): use tryOpenExternalUrl for markdown links in ThreadFeed (#5872) Co-authored-by: codex Co-authored-by: Julius Marminge (cherry picked from commit b277cc65e045899e7aa941e92d04f2b4996f27bb) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 110e1c302..a4b554dbd 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -28,7 +28,6 @@ import { import { ActivityIndicator, Image, - Linking, Platform, type LayoutChangeEvent, type NativeScrollEvent, @@ -51,6 +50,7 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { hasNativeSelectableMarkdownText, @@ -283,7 +283,7 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { { - void Linking.openURL(props.href); + void tryOpenExternalUrl(props.href, "markdown-link"); }} style={{ color: props.color, @@ -613,7 +613,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe onPress={ linkHref ? () => { - void Linking.openURL(linkHref); + void tryOpenExternalUrl(linkHref, "markdown-link"); } : undefined } @@ -1444,7 +1444,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } if (presentation.href) { - void Linking.openURL(presentation.href); + void tryOpenExternalUrl(presentation.href, "markdown-link"); } }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], From 12ed7c01d164fe8f146c0e48413f86ea3552262e Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Sat, 15 Aug 2026 12:44:51 +0200 Subject: [PATCH 22/99] fix(web): open the file a bare filename reference names (#6297) Co-authored-by: Rodrigo Brechard Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit 2cb1a26f061fa9029ccbe2a614f02bb14b22dd45) --- apps/web/src/components/ChatMarkdown.tsx | 53 ++++++++++- apps/web/src/workspaceBasenameLookup.test.ts | 93 ++++++++++++++++++++ apps/web/src/workspaceBasenameLookup.ts | 48 ++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/workspaceBasenameLookup.test.ts create mode 100644 apps/web/src/workspaceBasenameLookup.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 294a9e22a..ec88bc912 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -90,6 +90,13 @@ import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { projectEnvironment } from "../state/projects"; +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, + WORKSPACE_BASENAME_LOOKUP_LIMIT, +} from "../workspaceBasenameLookup"; import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; @@ -811,6 +818,7 @@ interface MarkdownFileLinkProps { theme: "light" | "dark"; threadRef?: ScopedThreadRef | undefined; onOpen: (targetPath: string) => Promise>; + onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; onOpenInBrowser?: (() => Promise>) | undefined; className?: string | undefined; } @@ -1116,6 +1124,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ theme, threadRef, onOpen, + onOpenInPanel, onOpenInBrowser, className, }: MarkdownFileLinkProps) { @@ -1159,8 +1168,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInEditor(); return; } - useRightPanelStore.getState().openFile(threadRef, workspaceRelativePath, line); - }, [handleOpenInEditor, line, threadRef, workspaceRelativePath]); + onOpenInPanel(workspaceRelativePath, line); + }, [handleOpenInEditor, line, onOpenInPanel, threadRef, workspaceRelativePath]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1336,6 +1345,7 @@ function areMarkdownFileLinkPropsEqual( previous.theme === next.theme && previous.threadRef === next.threadRef && previous.onOpen === next.onOpen && + previous.onOpenInPanel === next.onOpenInPanel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.className === next.className ); @@ -1355,6 +1365,9 @@ function ChatMarkdown({ const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, }); + const searchProjectEntries = useAtomQueryRunner(projectEnvironment.searchEntries, { + reportFailure: false, + }); const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); @@ -1457,6 +1470,40 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + // A bare filename resolves to the workspace root, which is rarely where the + // file is, so ask the index before opening. + const openFileInPanel = useCallback( + (workspaceRelativePath: string, line: number | undefined) => { + if (!threadRef) return; + // Claimed on every open so a synchronous one supersedes a lookup already + // in flight. + const isLatestLookup = claimWorkspaceBasenameLookup(); + const openAt = (path: string) => + useRightPanelStore.getState().openFile(threadRef, path, line); + if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { + openAt(workspaceRelativePath); + return; + } + void (async () => { + const result = await searchProjectEntries({ + environmentId: threadRef.environmentId, + input: { + cwd, + query: workspaceRelativePath, + limit: WORKSPACE_BASENAME_LOOKUP_LIMIT, + kind: "file", + }, + }); + const match = + result._tag === "Success" + ? pickWorkspaceBasenameMatch(workspaceRelativePath, result.value.entries) + : null; + if (!isLatestLookup()) return; + openAt(match ?? workspaceRelativePath); + })(); + }, + [cwd, searchProjectEntries, threadRef], + ); /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component * renderers that close over this message's metadata. useMemo keeps them stable until that * metadata changes. */ @@ -1490,6 +1537,7 @@ function ChatMarkdown({ theme={resolvedTheme} threadRef={threadRef} onOpen={openInPreferredEditor} + onOpenInPanel={openFileInPanel} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -1718,6 +1766,7 @@ function ChatMarkdown({ isStreaming, markdownFileLinkMetaByHref, onTaskListChange, + openFileInPanel, openInPreferredEditor, openExternalLinkInPreview, openMarkdownFileInPreview, diff --git a/apps/web/src/workspaceBasenameLookup.test.ts b/apps/web/src/workspaceBasenameLookup.test.ts new file mode 100644 index 000000000..e96e5f18b --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + claimWorkspaceBasenameLookup, + needsWorkspaceBasenameLookup, + pickWorkspaceBasenameMatch, +} from "./workspaceBasenameLookup"; + +describe("needsWorkspaceBasenameLookup", () => { + it("flags bare filenames", () => { + expect(needsWorkspaceBasenameLookup("ChatView.tsx")).toBe(true); + expect(needsWorkspaceBasenameLookup("Makefile")).toBe(true); + }); + + it("leaves anything with a directory alone", () => { + expect(needsWorkspaceBasenameLookup("apps/web/src/components/ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup("apps\\web\\ChatView.tsx")).toBe(false); + expect(needsWorkspaceBasenameLookup(" ")).toBe(false); + }); +}); + +describe("pickWorkspaceBasenameMatch", () => { + const entries = [ + { path: "apps/web/src/components/ChatView.test.tsx", kind: "file" as const }, + { path: "apps/web/src/components/ChatView.tsx", kind: "file" as const }, + ]; + + it("takes the first exact filename match, not the closest fuzzy one", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("ignores directories", () => { + expect( + pickWorkspaceBasenameMatch("components", [ + { path: "apps/web/src/components", kind: "directory" }, + { path: "apps/web/src/components/components", kind: "file" }, + ]), + ).toBe("apps/web/src/components/components"); + }); + + it("prefers the exactly-cased file over a case-only twin", () => { + expect( + pickWorkspaceBasenameMatch("foo.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBe("src/foo.ts"); + }); + + it("falls back to case-insensitive when only the casing differs", () => { + expect(pickWorkspaceBasenameMatch("chatview.tsx", entries)).toBe( + "apps/web/src/components/ChatView.tsx", + ); + }); + + it("returns null when the case-insensitive fallback is ambiguous", () => { + expect( + pickWorkspaceBasenameMatch("FOO.ts", [ + { path: "src/Foo.ts", kind: "file" }, + { path: "src/foo.ts", kind: "file" }, + ]), + ).toBeNull(); + }); + + it("returns null when nothing matches the name", () => { + expect(pickWorkspaceBasenameMatch("ChatView.tsx", [])).toBeNull(); + expect( + pickWorkspaceBasenameMatch("ChatView.tsx", [ + { path: "apps/web/src/components/ChatHeader.tsx", kind: "file" }, + ]), + ).toBeNull(); + }); +}); + +describe("claimWorkspaceBasenameLookup", () => { + it("keeps only the newest claim, whatever order the lookups settle in", () => { + const first = claimWorkspaceBasenameLookup(); + const second = claimWorkspaceBasenameLookup(); + + // The older lookup answering last must not reopen the panel behind the + // newer one. + expect(second()).toBe(true); + expect(first()).toBe(false); + }); + + it("stays valid while it is the only claim", () => { + const only = claimWorkspaceBasenameLookup(); + expect(only()).toBe(true); + expect(only()).toBe(true); + }); +}); diff --git a/apps/web/src/workspaceBasenameLookup.ts b/apps/web/src/workspaceBasenameLookup.ts new file mode 100644 index 000000000..b99d3ba4d --- /dev/null +++ b/apps/web/src/workspaceBasenameLookup.ts @@ -0,0 +1,48 @@ +// Enough hits to look past same-named neighbours (`ChatView.test.tsx`) without +// asking for a full listing on a single click. +export const WORKSPACE_BASENAME_LOOKUP_LIMIT = 25; + +// One counter for every caller: they all open the same panel, so the newest +// click wins regardless of which one started the lookup. +let latestLookupSequence = 0; + +/** Call the returned predicate when the search settles; false means a later click superseded it. */ +export function claimWorkspaceBasenameLookup(): () => boolean { + latestLookupSequence += 1; + const claimed = latestLookupSequence; + return () => claimed === latestLookupSequence; +} + +export interface WorkspaceEntryCandidate { + readonly path: string; + readonly kind: "file" | "directory"; +} + +function basenameOfPath(path: string): string { + const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; +} + +export function needsWorkspaceBasenameLookup(relativePath: string): boolean { + const trimmed = relativePath.trim(); + return trimmed.length > 0 && !trimmed.includes("/") && !trimmed.includes("\\"); +} + +export function pickWorkspaceBasenameMatch( + basename: string, + entries: ReadonlyArray, +): string | null { + const target = basename.trim(); + if (!target) return null; + const files = entries.filter((entry) => entry.kind === "file"); + const exact = files.find((entry) => basenameOfPath(entry.path) === target); + if (exact) return exact.path; + // Folded matching covers casing that drifted from disk, but `FOO.ts` against + // both `Foo.ts` and `foo.ts` has no right answer, so it resolves to nothing + // rather than opening whichever the index ranked first. + const folded = target.toLowerCase(); + const foldedMatches = files.filter( + (entry) => basenameOfPath(entry.path).toLowerCase() === folded, + ); + return foldedMatches.length === 1 ? (foldedMatches[0]?.path ?? null) : null; +} From 7b638b8a4a85d218fdb89eb74b173b761dbdbb0d Mon Sep 17 00:00:00 2001 From: Ulises Britos <45952970+repparw@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:31:36 -0300 Subject: [PATCH 23/99] fix(server): stop the provider title mirror from overwriting real thread titles (#5941) (cherry picked from commit ddee418a8d6d3e242ca26a8053a886ecc3b56b53) --- .../Layers/ProviderCommandReactor.ts | 15 +---- .../Layers/ProviderRuntimeIngestion.test.ts | 57 ++++++++++++++-- .../Layers/ProviderRuntimeIngestion.ts | 15 +++-- apps/server/src/orchestration/threadTitles.ts | 13 ++++ .../provider/Layers/OpenCodeAdapter.test.ts | 67 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 20 +++++- packages/contracts/src/provider.ts | 1 + 7 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/orchestration/threadTitles.ts diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f8838eb2b..18e91bd4e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -40,6 +40,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { forkParked, ServerActivation } from "../../serverActivation.ts"; +import { canReplaceThreadTitle, DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { resolveSourceControlWriterModelSelection, ServerSettingsService, @@ -103,7 +104,6 @@ const turnStartKeyForEvent = (event: ProviderIntentEvent): string => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; -const DEFAULT_THREAD_TITLE = "New thread"; const MAX_REGENERATION_ATTACHMENTS = 4; const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000; const MAX_FIRST_USER_TITLE_CONTEXT_CHARS = 2_000; @@ -239,18 +239,6 @@ export function providerErrorLabelFromInstanceHint(input: { ); } -function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { - const trimmedCurrentTitle = currentTitle.trim(); - if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { - return true; - } - - const trimmedTitleSeed = titleSeed?.trim(); - return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 - ? trimmedCurrentTitle === trimmedTitleSeed - : false; -} - function findProviderAdapterRequestError( cause: Cause.Cause, ): ProviderAdapterRequestError | undefined { @@ -639,6 +627,7 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 80e2afe08..9fbcc21fc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -55,6 +55,7 @@ import { ProviderRuntimeIngestionLive, runtimeEventToActivities, } from "./ProviderRuntimeIngestion.ts"; +import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; @@ -388,7 +389,10 @@ describe("ProviderRuntimeIngestion", () => { } }); - async function createHarness(options?: { serverSettings?: Partial }) { + async function createHarness(options?: { + serverSettings?: Partial; + threadTitle?: string; + }) { const workspaceRoot = makeTempDir("t3-provider-project-"); NodeFS.mkdirSync(NodePath.join(workspaceRoot, ".git")); const provider = createProviderServiceHarness(); @@ -447,7 +451,7 @@ describe("ProviderRuntimeIngestion", () => { commandId: CommandId.make("cmd-thread-create"), threadId: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), - title: "Thread", + title: options?.threadTitle ?? "Thread", modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", @@ -3412,7 +3416,7 @@ describe("ProviderRuntimeIngestion", () => { const thread = await waitForThread( harness.readModel, (entry) => - entry.title === "Renamed by provider" && + entry.title === "Thread" && entry.activities.some( (activity: ProviderRuntimeTestActivity) => activity.kind === "turn.plan.updated", ) && @@ -3427,7 +3431,7 @@ describe("ProviderRuntimeIngestion", () => { ), ); - expect(thread.title).toBe("Renamed by provider"); + expect(thread.title).toBe("Thread"); const planActivity = thread.activities.find( (activity: ProviderRuntimeTestActivity) => activity.id === "evt-turn-plan-updated", @@ -3468,6 +3472,51 @@ describe("ProviderRuntimeIngestion", () => { expect(checkpoint?.checkpointRef).toBe("provider-diff:evt-turn-diff-updated"); }); + it("mirrors a provider title only while the thread still has the default title", async () => { + const harness = await createHarness({ threadTitle: DEFAULT_THREAD_TITLE }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-default"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.title === "Renamed by provider", + ); + expect(thread.title).toBe("Renamed by provider"); + }); + + it("rejects a provider title once the thread has a real title", async () => { + const harness = await createHarness({ threadTitle: "User-set title" }); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "thread.metadata.updated", + eventId: asEventId("evt-thread-metadata-real"), + provider: ProviderDriverKind.make("codex"), + createdAt: now, + threadId: asThreadId("thread-1"), + payload: { + name: "Renamed by provider", + metadata: { source: "provider" }, + }, + }); + + await harness.drain(); + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect(thread?.title).toBe("User-set title"); + }); + it("projects context window updates into normalized thread activities", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 2d718da43..98435d781 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -50,6 +50,7 @@ import { } from "../Services/ProviderRuntimeIngestion.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { canReplaceThreadTitle } from "../threadTitles.ts"; /** * Thread activities are durable and replicated to every authenticated client @@ -2563,12 +2564,14 @@ const make = Effect.gen(function* () { } if (event.type === "thread.metadata.updated" && event.payload.name) { - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* providerCommandId(event, "thread-meta-update"), - threadId: thread.id, - title: event.payload.name, - }); + if (canReplaceThreadTitle(thread.title)) { + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* providerCommandId(event, "thread-meta-update"), + threadId: thread.id, + title: event.payload.name, + }); + } } if (event.type === "turn.diff.updated") { diff --git a/apps/server/src/orchestration/threadTitles.ts b/apps/server/src/orchestration/threadTitles.ts new file mode 100644 index 000000000..c9a9c4f72 --- /dev/null +++ b/apps/server/src/orchestration/threadTitles.ts @@ -0,0 +1,13 @@ +export const DEFAULT_THREAD_TITLE = "New thread"; + +export function canReplaceThreadTitle(currentTitle: string, titleSeed?: string): boolean { + const trimmedCurrentTitle = currentTitle.trim(); + if (trimmedCurrentTitle === DEFAULT_THREAD_TITLE) { + return true; + } + + const trimmedTitleSeed = titleSeed?.trim(); + return trimmedTitleSeed !== undefined && trimmedTitleSeed.length > 0 + ? trimmedCurrentTitle === trimmedTitleSeed + : false; +} diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaa..eea328e05 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1191,6 +1191,73 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("passes the thread title to session.create when provided", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-title-provided"); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + title: "Investigate reconnect failures", + }); + + NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1); + NodeAssert.equal( + runtimeMock.state.sessionCreateInputs[0]?.title, + "Investigate reconnect failures", + ); + }), + ); + + it.effect("does not mirror OpenCode's default placeholder session titles", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-placeholder-title"); + runtimeMock.state.subscribedEvents = [ + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "New session - 2026-08-09T10:20:30.456Z", + }, + }, + }, + { + type: "session.updated", + properties: { + info: { + id: "http://127.0.0.1:9999/session", + title: "Investigate reconnect failures", + }, + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.take(3), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + const metadataUpdated = events.filter((event) => event.type === "thread.metadata.updated"); + NodeAssert.equal(metadataUpdated.length, 1); + if (metadataUpdated[0]?.type === "thread.metadata.updated") { + NodeAssert.equal(metadataUpdated[0].payload.name, "Investigate reconnect failures"); + } + }), + ); + it.effect("writes provider-native observability records using the session thread id", () => Effect.gen(function* () { const nativeEvents: Array<{ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e..8f7e42c11 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -201,7 +201,24 @@ function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | und return undefined; } - return trimText(event.properties.info.title); + const title = trimText(event.properties.info.title); + // OpenCode mints a placeholder title at session.create when no title was + // provided, and re-emits it on every `session.updated`. Mirroring it would + // overwrite the thread's real title (openCodeEventSessionTitle feeds the + // `thread.metadata.updated` mirror). Ignore OpenCode's auto-generated + // placeholders so the thread isn't locked onto them. + if (!title || isOpenCodeDefaultTitle(title)) { + return undefined; + } + + return title; +} + +const OPENCODE_DEFAULT_TITLE_PATTERN = + /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +function isOpenCodeDefaultTitle(title: string): boolean { + return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } interface OpenCodeSessionContext { @@ -1302,6 +1319,7 @@ export function makeOpenCodeAdapter( } const createdSession = yield* runOpenCodeSdk("session.create", () => client.session.create({ + ...(input.title ? { title: input.title } : {}), permission: buildOpenCodePermissionRules(input.runtimeMode), }), ); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 541fea62f..9227221c4 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -62,6 +62,7 @@ export const ProviderSessionStartInput = Schema.Struct({ // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + title: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), From 1c5b7b67836d268ade3d9139851c22f8e6f769ae Mon Sep 17 00:00:00 2001 From: Guilherme Barros Date: Sat, 15 Aug 2026 13:31:56 +0200 Subject: [PATCH 24/99] fix(shared): match source-control providers by DNS label (#6175) (cherry picked from commit 178da6bc3210b82c4a83f33c8b149f623a3375e1) --- packages/shared/src/sourceControl.test.ts | 29 +++++++++++++++++++++++ packages/shared/src/sourceControl.ts | 10 +++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index bfee883dd..3842fa84b 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -91,4 +91,33 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("matches self-hosted providers by complete DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://github.example.com/owner/repo.git")?.kind, + ).toBe("github"); + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.example.com/group/repo.git")?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl("https://bitbucket.example.com/workspace/repo.git") + ?.kind, + ).toBe("bitbucket"); + }); + + it("does not match provider names embedded in unrelated DNS labels", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://notgithub.example.com/owner/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl("https://notgitlab.example.com/group/repo.git") + ?.kind, + ).toBe("unknown"); + expect( + detectSourceControlProviderFromRemoteUrl( + "https://notbitbucket.example.com/workspace/repo.git", + )?.kind, + ).toBe("unknown"); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index a29fe968e..ad6fa890b 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -167,12 +167,16 @@ function toBaseUrl(host: string): string { return `https://${host}`; } +function hasDnsLabel(host: string, label: string): boolean { + return host.split(".").includes(label); +} + function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); + return host === "github.com" || hasDnsLabel(host, "github"); } function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || host.includes("gitlab"); + return host === "gitlab.com" || hasDnsLabel(host, "gitlab"); } function isAzureDevOpsHost(host: string): boolean { @@ -188,7 +192,7 @@ function isAzureDevOpsHost(host: string): boolean { } function isBitbucketHost(host: string): boolean { - return host === "bitbucket.org" || host.includes("bitbucket"); + return host === "bitbucket.org" || hasDnsLabel(host, "bitbucket"); } export function detectSourceControlProviderFromRemoteUrl( From 420d1ea14e3f00367e7ce55e45b59de40e84b3b6 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:33:29 +0300 Subject: [PATCH 25/99] feat(desktop): Chrome-style hold-to-quit (#5508) (cherry picked from commit b7dbbbaf6c394621cba57cf58dfcc1845f445ef6) --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/wsl.test.ts | 12 ++ apps/desktop/src/preload.ts | 11 + .../settings/DesktopClientSettings.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 14 ++ apps/desktop/src/window/DesktopWindow.ts | 40 +++- apps/desktop/src/window/QuitHold.test.ts | 201 ++++++++++++++++++ apps/desktop/src/window/QuitHold.ts | 148 +++++++++++++ apps/web/src/AppRoot.test.tsx | 4 +- apps/web/src/AppRoot.tsx | 2 + apps/web/src/components/QuitHoldOverlay.tsx | 47 ++++ .../components/settings/SettingsPanels.tsx | 29 +++ .../settings/settingsSearch.test.ts | 5 + .../src/components/settings/settingsSearch.ts | 17 +- packages/contracts/src/ipc.ts | 6 + packages/contracts/src/settings.ts | 4 + 16 files changed, 539 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/window/QuitHold.test.ts create mode 100644 apps/desktop/src/window/QuitHold.ts create mode 100644 apps/web/src/components/QuitHoldOverlay.tsx diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0e31082af..ac1ee8792 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -5,6 +5,7 @@ export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; export const WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:window-fullscreen-state"; export const UPDATE_STATE_CHANNEL = "desktop:update-state"; diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index 3e07ae7f3..38435e286 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -10,8 +10,11 @@ import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; import * as DesktopShutdown from "../../app/DesktopShutdown.ts"; import * as DesktopState from "../../app/DesktopState.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronTheme from "../../electron/ElectronTheme.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts"; import * as DesktopWindow from "../../window/DesktopWindow.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; @@ -70,6 +73,15 @@ const unusedLifecycleRuntimeLayer = Layer.mergeAll( ElectronTheme.ElectronTheme, ElectronTheme.ElectronTheme.of({} as ElectronTheme.ElectronTheme["Service"]), ), + Layer.succeed( + ElectronDialog.ElectronDialog, + ElectronDialog.ElectronDialog.of({} as ElectronDialog.ElectronDialog["Service"]), + ), + Layer.succeed( + ElectronWindow.ElectronWindow, + ElectronWindow.ElectronWindow.of({} as ElectronWindow.ElectronWindow["Service"]), + ), + DesktopClientSettings.layerTest(), ); describe("WSL IPC", () => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 61e345b90..cbbadb708 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -117,6 +117,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.MENU_ACTION_CHANNEL, wrappedListener); }; }, + onQuitShortcut: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + if (state !== "down" && state !== "up") return; + listener(state); + }; + + ipcRenderer.on(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.QUIT_SHORTCUT_CHANNEL, wrappedListener); + }; + }, getWindowFullscreenState: () => ipcRenderer.sendSync(IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL) === true, onWindowFullscreenStateChange: (listener) => { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1d2acba7f..ceff09a5c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,6 +13,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + confirmQuit: true, confirmThreadArchive: true, confirmThreadDelete: false, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 840e0c803..2677d5153 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -37,6 +37,8 @@ import * as DesktopConfig from "../app/DesktopConfig.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; @@ -128,6 +130,14 @@ function makeFakeBrowserWindow() { }; } +const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ + get: Effect.succeed(Option.none()), +}); + +const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ + quit: Effect.void, +}); + const desktopAssetsLayer = Layer.succeed(DesktopAssets.DesktopAssets, { iconPaths: Effect.succeed({ ico: Option.none(), @@ -253,8 +263,10 @@ function makeTestLayer(input: { desktopAssetsLayer, desktopEnvironmentLayer, desktopAppSettingsLayer, + desktopClientSettingsLayer, desktopServerExposureLayer, DesktopState.layer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: (url) => @@ -356,7 +368,9 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n desktopAssetsLayer, desktopEnvironmentLayer, DesktopAppSettings.layerTest(), + desktopClientSettingsLayer, desktopServerExposureLayer, + electronAppLayer, electronMenuLayer, Layer.succeed(ElectronShell.ElectronShell, { openExternal: () => Effect.succeed(true), diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 2ae3d3532..9018b9b92 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -8,6 +8,8 @@ import * as Ref from "effect/Ref"; import * as Electron from "electron"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; + import * as DesktopAssets from "../app/DesktopAssets.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import { makeComponentLogger } from "../app/DesktopObservability.ts"; @@ -16,9 +18,16 @@ import { getDesktopUrl } from "../electron/ElectronProtocol.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { MENU_ACTION_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL } from "../ipc/channels.ts"; +import { + MENU_ACTION_CHANNEL, + QUIT_SHORTCUT_CHANNEL, + WINDOW_FULLSCREEN_STATE_CHANNEL, +} from "../ipc/channels.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { makeQuitHoldHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux @@ -51,6 +60,8 @@ type DesktopWindowRuntimeServices = | DesktopEnvironment.DesktopEnvironment | DesktopAssets.DesktopAssets | DesktopAppSettings.DesktopAppSettings + | DesktopClientSettings.DesktopClientSettings + | ElectronApp.ElectronApp | ElectronMenu.ElectronMenu | ElectronShell.ElectronShell | ElectronTheme.ElectronTheme @@ -261,6 +272,8 @@ export const make = Effect.gen(function* () { const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; + const electronApp = yield* ElectronApp.ElectronApp; // Window-side latch for the primary backend's readiness. Set by // handleBackendReady (driven by the pool's onReady callback), cleared // by handleBackendNotReady (driven by onShutdown). Only consumed by @@ -533,7 +546,32 @@ export const make = Effect.gen(function* () { // close-terminal shortcut can outlive the terminal that handled its first // press, so reject repeats before they reach the native window accelerator. // Deliberate presses still flow through the renderer or native menu. + // Chrome-style hold-to-quit: intercept the quit accelerator before the + // native menu sees it and only quit after the shortcut is held. The + // renderer shows the "Hold to Quit" hint via QUIT_SHORTCUT_CHANNEL. + const quitHoldHandler = makeQuitHoldHandler({ + platform: environment.platform, + isEnabled: () => + runPromise( + Effect.map( + clientSettings.get, + Option.match({ + onNone: () => DEFAULT_CLIENT_SETTINGS.confirmQuit, + onSome: (settings) => settings.confirmQuit, + }), + ), + ), + notify: (state) => { + if (!window.isDestroyed()) { + window.webContents.send(QUIT_SHORTCUT_CHANNEL, state); + } + }, + quit: () => { + void runPromise(electronApp.quit); + }, + }); window.webContents.on("before-input-event", (event, input) => { + quitHoldHandler(event, input); if (input.type !== "keyDown" || !input.isAutoRepeat) return; const modifier = environment.platform === "darwin" ? input.meta : input.control; if (modifier && !input.alt && !input.shift && input.key.toLowerCase() === "w") { diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts new file mode 100644 index 000000000..c900a8654 --- /dev/null +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -0,0 +1,201 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + makeQuitHoldHandler, + QUIT_DOUBLE_TAP_MS, + QUIT_HOLD_DURATION_MS, + QUIT_HOLD_RELEASE_GRACE_MS, +} from "./QuitHold.ts"; +import type { QuitHoldKeyInput, QuitHoldState } from "./QuitHold.ts"; + +function makeInput(overrides: Partial): QuitHoldKeyInput { + return { + type: "keyDown", + key: "q", + meta: true, + control: false, + alt: false, + shift: false, + isAutoRepeat: false, + ...overrides, + }; +} + +function makeHarness(options?: { + enabled?: boolean; + platform?: NodeJS.Platform; + isEnabled?: () => Promise; +}) { + const notifications: Array = []; + const quit = vi.fn(); + const handler = makeQuitHoldHandler({ + platform: options?.platform ?? "darwin", + isEnabled: options?.isEnabled ?? (() => Promise.resolve(options?.enabled ?? true)), + notify: (state) => notifications.push(state), + quit, + }); + const preventDefault = vi.fn(); + const send = async (input: QuitHoldKeyInput) => { + handler({ preventDefault }, input); + // Let the isEnabled promise settle. + await Promise.resolve(); + await Promise.resolve(); + }; + // Simulates the OS auto-repeating the held shortcut every `intervalMs`. + const holdFor = async ( + durationMs: number, + repeatOverrides: Partial = {}, + intervalMs = 100, + ) => { + for (let elapsed = 0; elapsed < durationMs; elapsed += intervalMs) { + vi.advanceTimersByTime(intervalMs); + await send(makeInput({ isAutoRepeat: true, ...repeatOverrides })); + } + }; + return { notifications, quit, preventDefault, send, holdFor }; +} + +describe("makeQuitHoldHandler", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("shows the hint on a tap without quitting, even when the release is never seen", async () => { + // macOS suppresses the letter's keyUp while Cmd is held, so a tap may + // produce no keyUp at all. Quit must still not fire. + const harness = makeHarness(); + await harness.send(makeInput({})); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual(["down"]); + + vi.advanceTimersByTime(QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + expect(harness.quit).not.toHaveBeenCalled(); + // The watchdog dismisses the hint once the press is clearly over. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("quits once the shortcut auto-repeats past the hold duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(QUIT_HOLD_DURATION_MS - 200); + expect(harness.quit).not.toHaveBeenCalled(); + await harness.holdFor(400); + expect(harness.quit).toHaveBeenCalledTimes(1); + // Exactly one hint cycle for the whole hold. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("does not quit when the hold stops before the duration", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + await harness.send(makeInput({ type: "keyUp" })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("cancels the hold when the modifier is released first", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp", key: "Meta", meta: false })); + expect(harness.notifications).toEqual(["down", "up"]); + vi.advanceTimersByTime((QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS) * 2); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("quits immediately on a single press when disabled", async () => { + const harness = makeHarness({ enabled: false }); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + // The hint is dismissed in case the quit gets cancelled downstream. + expect(harness.notifications).toEqual(["down", "up"]); + }); + + it("discards a stale isEnabled resolution from a superseded press", async () => { + // Press #1's isEnabled is still pending when the user releases and + // presses again; its late resolution must not act for press #2. + const resolvers: Array<(enabled: boolean) => void> = []; + const harness = makeHarness({ + isEnabled: () => new Promise((resolve) => resolvers.push(resolve)), + }); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + // Outside the double-tap window, so the second press starts a new hold. + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(resolvers).toHaveLength(2); + + // Press #1 resolves late with "disabled" — it must not quit press #2. + resolvers[0]?.(false); + await Promise.resolve(); + await Promise.resolve(); + expect(harness.quit).not.toHaveBeenCalled(); + + // Press #2 resolves enabled and completes a full hold. + resolvers[1]?.(true); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("quits on a quick double tap, even when the first release was never seen", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS - 100); + await harness.send(makeInput({})); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); + + it("treats two slow taps as separate presses", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ type: "keyUp" })); + vi.advanceTimersByTime(QUIT_DOUBLE_TAP_MS + 100); + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("cancels the hold when another key interrupts it", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.holdFor(500); + // Shift pressed mid-hold breaks the gesture... + await harness.send(makeInput({ shift: true })); + expect(harness.notifications).toEqual(["down", "up"]); + // ...so later repeats past the threshold must not quit. + await harness.holdFor(QUIT_HOLD_DURATION_MS); + expect(harness.quit).not.toHaveBeenCalled(); + }); + + it("does not count an interrupted press toward a double tap", async () => { + const harness = makeHarness(); + await harness.send(makeInput({})); + await harness.send(makeInput({ shift: true })); + // A fresh press right after the interruption starts a new hold, not a + // double-tap quit. + await harness.send(makeInput({})); + expect(harness.quit).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual(["down", "up", "down"]); + }); + + it("ignores other shortcuts", async () => { + const harness = makeHarness(); + await harness.send(makeInput({ key: "w" })); + await harness.send(makeInput({ shift: true })); + await harness.send(makeInput({ meta: false })); + expect(harness.preventDefault).not.toHaveBeenCalled(); + expect(harness.notifications).toEqual([]); + }); + + it("uses control on non-mac platforms", async () => { + const harness = makeHarness({ platform: "linux" }); + await harness.send(makeInput({ meta: false, control: true })); + expect(harness.preventDefault).toHaveBeenCalledTimes(1); + await harness.holdFor(QUIT_HOLD_DURATION_MS + 200, { meta: false, control: true }); + expect(harness.quit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts new file mode 100644 index 000000000..ea2fc7854 --- /dev/null +++ b/apps/desktop/src/window/QuitHold.ts @@ -0,0 +1,148 @@ +// @effect-diagnostics globalDate:off globalTimers:off -- Synchronous before-input-event handler; key events must be timed and the watchdog scheduled outside any Effect runtime. + +// Chrome-style hold-to-quit. The quit accelerator is intercepted in +// before-input-event (which runs before the native menu accelerator), and the +// app only quits once the shortcut has been held for QUIT_HOLD_DURATION_MS. +// A quick tap just shows the renderer's "Hold to Quit" hint, and a second tap +// within QUIT_DOUBLE_TAP_MS quits immediately. Quitting from the application +// menu itself is untouched and quits immediately. +export const QUIT_HOLD_DURATION_MS = 1200; +// A second quick tap of the shortcut is the user insisting: quit immediately. +export const QUIT_DOUBLE_TAP_MS = 500; +// "Still held" is proven by auto-repeat keydowns, not by the absence of a +// release: macOS suppresses a letter's keyUp while the command key is down, so +// a tap's release can go completely unseen and a release-based timer would +// quit anyway. The press is treated as released once no key event has arrived +// for QUIT_HOLD_RELEASE_GRACE_MS past the hold duration. Keyboards with +// auto-repeat disabled cannot hold-to-quit and fall back to the menu's Quit. +export const QUIT_HOLD_RELEASE_GRACE_MS = 600; + +export type QuitHoldState = "down" | "up"; + +export interface QuitHoldKeyInput { + readonly type: string; + readonly key: string; + readonly meta: boolean; + readonly control: boolean; + readonly alt: boolean; + readonly shift: boolean; + readonly isAutoRepeat: boolean; +} + +export interface QuitHoldOptions { + readonly platform: NodeJS.Platform; + readonly isEnabled: () => Promise; + readonly notify: (state: QuitHoldState) => void; + readonly quit: () => void; +} + +export function makeQuitHoldHandler( + options: QuitHoldOptions, +): (event: { preventDefault: () => void }, input: QuitHoldKeyInput) => void { + const modifierKey = options.platform === "darwin" ? "meta" : "control"; + let watchdog: NodeJS.Timeout | undefined; + let holding = false; + // Set once isEnabled resolves true; auto-repeats may only quit when armed. + let armed = false; + let heldSince = 0; + let lastPressAt = 0; + // Incremented on every new press and every release/quit so a pending + // isEnabled() resolution from a superseded press cannot arm (or quit for) + // the current one. + let generation = 0; + + const clearWatchdog = () => { + if (watchdog !== undefined) { + clearTimeout(watchdog); + watchdog = undefined; + } + }; + + const release = () => { + if (!holding) return; + generation += 1; + holding = false; + armed = false; + clearWatchdog(); + options.notify("up"); + }; + + // Dismisses the overlay first: if the quit is cancelled downstream the + // renderer must not be left with a stuck "Hold to Quit" hint. + const quitNow = () => { + release(); + options.quit(); + }; + + return (event, input) => { + const key = input.key.toLowerCase(); + if (input.type === "keyUp") { + if (key === "q" || key === modifierKey) release(); + return; + } + if (input.type !== "keyDown") return; + + const modifierDown = options.platform === "darwin" ? input.meta : input.control; + if (!modifierDown || input.alt || input.shift || key !== "q") { + // Any other key (or an extra modifier) pressed mid-hold breaks the + // gesture; without this the hold timer keeps running through the + // interruption and the next qualifying repeat would quit early. The + // interrupted press also stops counting toward a double tap — but only + // here, not in release(), which runs mid-restart on an unseen-release + // re-press and must not wipe that press's own tap timestamp. + if (holding && !input.isAutoRepeat) { + lastPressAt = 0; + release(); + } + return; + } + + event.preventDefault(); + + if (input.isAutoRepeat) { + if (armed && Date.now() - heldSince >= QUIT_HOLD_DURATION_MS) { + quitNow(); + } + return; + } + + const now = Date.now(); + const previousPressAt = lastPressAt; + lastPressAt = now; + // A fresh keydown while "holding" means the key came back down after a + // release macOS never delivered — so both branches below see real taps. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_TAP_MS) { + quitNow(); + return; + } + if (holding) release(); + + generation += 1; + const pressGeneration = generation; + holding = true; + heldSince = now; + options.notify("down"); + void options.isEnabled().then( + (enabled) => { + if (generation !== pressGeneration) return; + if (!enabled) { + // Hold-to-quit disabled: a single press quits immediately. + quitNow(); + return; + } + armed = true; + // No auto-repeat by then means the key was released (possibly with a + // suppressed keyUp) or repeat is disabled; either way, don't quit. + watchdog = setTimeout(() => { + watchdog = undefined; + release(); + }, QUIT_HOLD_DURATION_MS + QUIT_HOLD_RELEASE_GRACE_MS); + }, + // A failed settings read must never strand the quit request. + () => { + if (generation !== pressGeneration) return; + quitNow(); + }, + ); + }; +} diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d743476..791004b74 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; import { AppRoot } from "./AppRoot"; @@ -16,9 +17,10 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(3); + expect(children).toHaveLength(4); expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); + expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84..857125c9f 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -2,6 +2,7 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; +import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -16,6 +17,7 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + ); } diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx new file mode 100644 index 000000000..29c044015 --- /dev/null +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; + +import { isMacPlatform } from "../lib/utils"; + +// Matches the hold duration in apps/desktop/src/window/QuitHold.ts: the hint +// from a quick tap lingers for as long as a full hold would have taken. +const HIDE_AFTER_RELEASE_MS = 1200; + +/** + * Chrome-style "Hold ⌘Q to Quit" hint. The desktop main process intercepts + * the quit accelerator and pushes press/release states; a quick tap shows + * this pill while a full hold quits the app. + */ +export function QuitHoldOverlay() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const subscribe = window.desktopBridge?.onQuitShortcut; + if (!subscribe) return; + let hideTimer: number | undefined; + const unsubscribe = subscribe((state) => { + window.clearTimeout(hideTimer); + if (state === "down") { + setVisible(true); + return; + } + hideTimer = window.setTimeout(() => setVisible(false), HIDE_AFTER_RELEASE_MS); + }); + return () => { + window.clearTimeout(hideTimer); + unsubscribe(); + }; + }, []); + + if (!visible) return null; + const shortcut = isMacPlatform(navigator.platform) ? "⌘Q" : "Ctrl+Q"; + return ( +
    +
    + Hold {shortcut} to Quit +
    +
    + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6599011e4..067c33fb5 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -534,11 +534,15 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), + ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit + ? ["Quit confirmation"] + : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, + settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, @@ -656,6 +660,7 @@ export function useSettingsRestore(onRestored?: () => void) { addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, @@ -2279,6 +2284,30 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + + updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) + } + /> + ) : null + } + control={ + updateSettings({ confirmQuit: Boolean(checked) })} + aria-label="Hold to quit" + /> + } + /> + ) : null} + { expect(searchSettings(" ", ITEMS)).toEqual([]); }); + it("hides desktop-only settings from browser search", () => { + expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); + expect(searchSettings("quit confirmation")).toEqual([]); + }); + it("keeps catalog result ids unique", () => { const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); expect(new Set(ids).size).toBe(ids.length); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 9b956ea9f..e03e48ef1 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,3 +1,5 @@ +import { isElectron } from "~/env"; + export type SettingsPath = | "/settings/general" | "/settings/appearance" @@ -12,6 +14,9 @@ export interface SettingsSearchItem { readonly title: string; readonly to: SettingsPath; readonly targetId?: string; + // Its row only renders in the desktop app, so a browser result would land on + // an anchor that isn't there. + readonly desktopOnly?: boolean; } /** @@ -149,6 +154,12 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Delete confirmation", to: "/settings/general", }, + { + id: "quit-confirmation", + title: "Hold to quit", + to: "/settings/general", + desktopOnly: true, + }, { id: "text-generation-model", title: "Text generation model", @@ -231,5 +242,9 @@ export function searchSettings( const normalizedQuery = normalizeSearchText(query); if (normalizedQuery.length === 0) return []; - return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery)); + return items.filter( + (item) => + (isElectron || item.desktopOnly !== true) && + normalizeSearchText(item.title).includes(normalizedQuery), + ); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 09d7d7a46..3341c0bb0 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1080,6 +1080,12 @@ export interface DesktopBridge { */ probeRemoteEditors?: () => Promise; onMenuAction: (listener: (action: string) => void) => () => void; + /** + * Hold-to-quit hint pushes: "down" when the quit shortcut is first pressed, + * "up" when it is released before the hold completes. Optional: older + * desktop builds never emit it. + */ + onQuitShortcut?: (listener: (state: "down" | "up") => void) => () => void; getWindowFullscreenState: () => boolean; onWindowFullscreenStateChange: (listener: (fullscreen: boolean) => void) => () => void; getUpdateState: () => Promise; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 247771cf7..5f1801440 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -112,6 +112,9 @@ export const FontFamilyPreference = Schema.String.check(Schema.isMaxLength(200)) export type FontFamilyPreference = typeof FontFamilyPreference.Type; export const ClientSettingsSchema = Schema.Struct({ + // Desktop-only: require holding the quit shortcut (Cmd/Ctrl+Q) before the + // app quits; a quick tap only shows a hint. Browser clients ignore it. + confirmQuit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( @@ -815,6 +818,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + confirmQuit: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), From 194b4060a53a841ead0c5b58ee15c85812491d0e Mon Sep 17 00:00:00 2001 From: Taras Date: Sat, 15 Aug 2026 14:37:02 +0300 Subject: [PATCH 26/99] fix(gitlab): submit review comments on context lines (#6348) (cherry picked from commit d94fbda344398bd266b9f6645e531eb17d3c9e4e) --- .../BitbucketPullRequestApi.test.ts | 8 +- .../pullRequest/BitbucketPullRequestApi.ts | 16 +- .../pullRequest/GitHubPullRequestCli.test.ts | 2 +- .../pullRequest/GitLabPullRequestCli.test.ts | 9 +- .../src/pullRequest/GitLabPullRequestCli.ts | 21 +- .../pullRequest/PullRequestService.test.ts | 2 +- .../pullRequest/gitHubPullRequestJson.test.ts | 12 +- .../src/pullRequest/gitHubPullRequestJson.ts | 20 +- .../pullRequest/PullRequestCodeTab.tsx | 45 +++- .../pullRequestReviewStore.test.ts | 2 +- .../pullRequest/pullRequestReviewStore.ts | 11 +- apps/web/src/reviewCommentContext.ts | 221 ++++++++++++++++-- packages/contracts/src/pullRequest.ts | 23 +- 13 files changed, 338 insertions(+), 54 deletions(-) diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 4120cf55e..8945ecc5e 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -776,7 +776,13 @@ layer("BitbucketPullRequestApi.layer", (it) => { number: 7, verdict: "request-changes", body: "Two things.", - comments: [{ path: "src/a.ts", line: 12, side: "left", body: "why remove?" }], + comments: [ + { + path: "src/a.ts", + position: { kind: "deleted", oldLine: 12 }, + body: "why remove?", + }, + ], }); expect(callAt(0).url).toContain("/pullrequests/7/comments"); diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index a2c57bfc5..5b3149b0d 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -12,6 +12,7 @@ import type { PullRequestMergeMethod, PullRequestMergeability, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -364,6 +365,19 @@ function mergeStrategy(method: PullRequestMergeMethod | undefined): string { } } +function bitbucketReviewPosition( + position: PullRequestReviewPosition, +): { readonly from: number } | { readonly to: number } { + switch (position.kind) { + case "added": + return { to: position.newLine }; + case "deleted": + return { from: position.oldLine }; + case "context": + return position.side === "left" ? { from: position.oldLine } : { to: position.newLine }; + } +} + export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; @@ -794,7 +808,7 @@ export const make = Effect.gen(function* () { content: { raw: comment.body }, inline: { path: comment.path, - ...(comment.side === "left" ? { from: comment.line } : { to: comment.line }), + ...bitbucketReviewPosition(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 848c4cd5e..d1af03db9 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1624,7 +1624,7 @@ layer("GitHubPullRequestCli.layer", (it) => { number: 7, verdict: "approve", body: "Looks right.", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }); expect(callAt(0).args).toEqual([ diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index c33e01c2d..014d91a02 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -1018,7 +1018,12 @@ layer("GitLabPullRequestCli.layer", (it) => { verdict: "approve", body: "Looks right.", comments: [ - { path: "src/b.ts", oldPath: "src/a.ts", line: 4, side: "left", body: "why remove?" }, + { + path: "src/b.ts", + oldPath: "src/a.ts", + position: { kind: "deleted", oldLine: 4 }, + body: "why remove?", + }, ], }); @@ -1197,7 +1202,7 @@ layer("GitLabPullRequestCli.layer", (it) => { number: 7, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 4, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 17c23bf86..9f968dddb 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -14,6 +14,7 @@ import type { PullRequestReaction, PullRequestReactionContent, PullRequestReviewCommentDraft, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -400,6 +401,22 @@ function projectPath(repository: string): string { return encodeURIComponent(repository.trim()); } +function gitLabReviewPositionLines( + position: PullRequestReviewPosition, +): + | { readonly new_line: number } + | { readonly old_line: number } + | { readonly old_line: number; readonly new_line: number } { + switch (position.kind) { + case "added": + return { new_line: position.newLine }; + case "deleted": + return { old_line: position.oldLine }; + case "context": + return { old_line: position.oldLine, new_line: position.newLine }; + } +} + function stateParam(state: PullRequestListState): string { // GitLab's `closed` already excludes merged merge requests, so no extra filter is needed, // and it spans every state under `all`. @@ -1324,9 +1341,7 @@ export const make = Effect.gen(function* () { // draft carries the name the file had before the change. old_path: comment.oldPath ?? comment.path, new_path: comment.path, - ...(comment.side === "left" - ? { old_line: comment.line } - : { new_line: comment.line }), + ...gitLabReviewPositionLines(comment.position), }, }), }), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 243cfe06c..456a5023b 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1534,7 +1534,7 @@ it.effect("refuses line comments on a host that takes only a summary", () => number: 1, verdict: "comment", body: "", - comments: [{ path: "src/a.ts", line: 1, side: "right", body: "nit" }], + comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 1 }, body: "nit" }], }), ); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index a3c3524a6..946394dcd 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1147,8 +1147,16 @@ describe("review submission payload", () => { verdict: "request-changes", body: "Two things.", comments: [ - { path: "src/a.ts", line: 12, side: "right", body: "rename this" }, - { path: "src/b.ts", line: 3, side: "left", body: "why remove?" }, + { + path: "src/a.ts", + position: { kind: "added", newLine: 12 }, + body: "rename this", + }, + { + path: "src/b.ts", + position: { kind: "deleted", oldLine: 3 }, + body: "why remove?", + }, ], }), ) as Record; diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index e113b87d8..7b9ff9d41 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -17,6 +17,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, + PullRequestReviewPosition, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, @@ -933,6 +934,22 @@ export const REVIEW_DISMISSALS_GRAPHQL_QUERY = `query($owner: String!, $name: St } }`; +function gitHubReviewPosition(position: PullRequestReviewPosition): { + readonly line: number; + readonly side: "LEFT" | "RIGHT"; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "RIGHT" }; + case "deleted": + return { line: position.oldLine, side: "LEFT" }; + case "context": + return position.side === "left" + ? { line: position.oldLine, side: "LEFT" } + : { line: position.newLine, side: "RIGHT" }; + } +} + /** The whole review as one request body, which is how GitHub keeps it invisible until sent. */ export function buildReviewSubmissionJson(input: { readonly verdict: PullRequestReviewVerdict; @@ -944,8 +961,7 @@ export function buildReviewSubmissionJson(input: { body: input.body, comments: input.comments.map((comment) => ({ path: comment.path, - line: comment.line, - side: comment.side === "left" ? ("LEFT" as const) : ("RIGHT" as const), + ...gitHubReviewPosition(comment.position), body: comment.body, })), }); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 71f3ffc7f..776a4d671 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -6,6 +6,7 @@ import type { PullRequestDiffSide, PullRequestOmittedFileStat, PullRequestRef, + PullRequestReviewPosition, PullRequestReviewThread, } from "@t3tools/contracts"; import { @@ -43,7 +44,11 @@ import { } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { createPullRequestDiffFileContentsLoader } from "~/lib/diffFileContents"; -import { buildDiffReviewComment, type ReviewCommentContext } from "~/reviewCommentContext"; +import { + buildDiffReviewComment, + resolveDiffReviewPosition, + type ReviewCommentContext, +} from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -129,8 +134,7 @@ interface DraftAnchor { readonly path: string; /** What the file was called before the change, for the hosts that resolve a position by both. */ readonly oldPath: string | null; - readonly line: number; - readonly side: PullRequestDiffSide; + readonly position: PullRequestReviewPosition; /** The whole selection, which the comment collapses to one line but a question keeps. */ readonly range: SelectedLineRange; } @@ -148,8 +152,21 @@ function toViewerSide(side: PullRequestDiffSide) { return side === "left" ? ("deletions" as const) : ("additions" as const); } -function fromViewerSide(side: string | undefined): PullRequestDiffSide { - return side === "deletions" ? "left" : "right"; +function getReviewPositionAnchor(position: PullRequestReviewPosition): { + line: number; + side: PullRequestDiffSide; +} { + switch (position.kind) { + case "added": + return { line: position.newLine, side: "right" }; + case "deleted": + return { line: position.oldLine, side: "left" }; + case "context": + return { + line: position.side === "left" ? position.oldLine : position.newLine, + side: position.side, + }; + } } /** @@ -442,10 +459,14 @@ export function PullRequestCodeTab({ if (commit === null) { for (const comment of pendingComments) { if (comment.path !== path) continue; - groupAt(comment.side, comment.line).pending.push(comment); + const anchor = getReviewPositionAnchor(comment.position); + groupAt(anchor.side, anchor.line).pending.push(comment); } } - if (draft?.fileKey === fileKey) groupAt(draft.side, draft.line).draft = true; + if (draft?.fileKey === fileKey) { + const anchor = getReviewPositionAnchor(draft.position); + groupAt(anchor.side, anchor.line).draft = true; + } const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); @@ -594,12 +615,13 @@ export function PullRequestCodeTab({ // that silently lost its first line on the other hosts would be worse than one line. const path = resolveFileDiffPath(file); const previousPath = resolveFileDiffPreviousPath(file); + const position = resolveDiffReviewPosition(file, range.end, range.endSide ?? range.side); + if (position === null) return; setDraft({ fileKey: item.id, path, oldPath: previousPath === path ? null : previousPath, - line: range.end, - side: fromViewerSide(range.endSide ?? range.side), + position, range, }); }, @@ -842,7 +864,7 @@ export function PullRequestCodeTab({ {annotation.metadata.draft && draft ? ( { diff --git a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts index 8e207c252..41906a710 100644 --- a/apps/web/src/components/pullRequest/pullRequestReviewStore.ts +++ b/apps/web/src/components/pullRequest/pullRequestReviewStore.ts @@ -6,17 +6,10 @@ * hosts that have no pending review of their own. That also means a draft lives only as long * as the tab does, which is why this is deliberately not persisted. */ -import type { ProjectId, PullRequestDiffSide, PullRequestRef } from "@t3tools/contracts"; +import type { ProjectId, PullRequestRef, PullRequestReviewCommentDraft } from "@t3tools/contracts"; import { create } from "zustand"; -export interface PendingReviewComment { - readonly id: string; - readonly path: string; - /** The line in the file the comment's side names: the new file on the right, the old on the left. */ - readonly line: number; - readonly side: PullRequestDiffSide; - readonly body: string; -} +export type PendingReviewComment = PullRequestReviewCommentDraft & { readonly id: string }; /** * A counter rather than anything derived from the comment: two remarks on one line can be the diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 7ce319973..41f75eb38 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -1,6 +1,15 @@ import type { FileDiffMetadata, SelectedLineRange, SelectionSide } from "@pierre/diffs"; +import type { PullRequestReviewPosition } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; +const ReviewCommentSelectionSchema = Schema.Struct({ + start: Schema.Number, + side: Schema.Literals(["additions", "deletions"]), + end: Schema.Number, + endSide: Schema.Literals(["additions", "deletions"]), +}); +type ReviewCommentSelection = typeof ReviewCommentSelectionSchema.Type; + export const ReviewCommentContextSchema = Schema.Struct({ id: Schema.String, sectionId: Schema.String, @@ -12,6 +21,7 @@ export const ReviewCommentContextSchema = Schema.Struct({ text: Schema.String, diff: Schema.String, fenceLanguage: Schema.optional(Schema.String), + selection: Schema.optional(ReviewCommentSelectionSchema), }); export interface ReviewCommentContext { @@ -25,6 +35,7 @@ export interface ReviewCommentContext { readonly text: string; readonly diff: string; readonly fenceLanguage?: string | undefined; + readonly selection?: ReviewCommentSelection | undefined; } interface DiffReviewLine { @@ -267,10 +278,44 @@ function stripTrailingNewline(value: string): string { return value.endsWith("\n") ? value.slice(0, -1) : value; } -function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray { +function buildDiffReviewLines( + fileDiff: FileDiffMetadata, + includeExpandedContext: boolean, + slice?: { readonly startIndex: number; readonly endIndex: number }, +): ReadonlyArray { const rows: DiffReviewLine[] = []; + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const pushRow = (row: DiffReviewLine) => { + if (!slice || (rowIndex >= slice.startIndex && rowIndex <= slice.endIndex)) { + rows.push(row); + } + rowIndex += 1; + }; + const pushContextGap = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const firstOffset = slice ? Math.max(0, slice.startIndex - rowIndex) : 0; + const lastOffset = slice ? Math.min(count - 1, slice.endIndex - rowIndex) : count - 1; + for (let offset = firstOffset; offset <= lastOffset; offset += 1) { + rows.push({ + change: "context", + oldLineNumber: oldStart + offset, + newLineNumber: newStart + offset, + content: stripTrailingNewline(fileDiff.additionLines[newStart + offset - 1] ?? ""), + }); + } + rowIndex += count; + }; for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldHunkStart = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newHunkStart = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min(oldHunkStart - oldContextStart, newHunkStart - newContextStart); + pushContextGap(oldContextStart, newContextStart, contextLines); + } + let oldLineNumber = hunk.deletionStart; let newLineNumber = hunk.additionStart; let deletionLineIndex = hunk.deletionLineIndex; @@ -279,7 +324,7 @@ function buildDiffReviewLines(fileDiff: FileDiffMetadata): ReadonlyArray, + fileDiff: FileDiffMetadata, lineNumber: number, side: SelectionSide | undefined, + includeExpandedContext = !fileDiff.isPartial, ): number { - const preferredKey = side === "deletions" ? "oldLineNumber" : "newLineNumber"; - const preferredIndex = lines.findIndex((line) => line[preferredKey] === lineNumber); - if (preferredIndex >= 0) return preferredIndex; - const fallbackKey = preferredKey === "oldLineNumber" ? "newLineNumber" : "oldLineNumber"; - return lines.findIndex((line) => line[fallbackKey] === lineNumber); + const findOnSide = (selectedSide: "left" | "right") => { + let rowIndex = 0; + let oldContextStart = 1; + let newContextStart = 1; + const findContextIndex = (oldStart: number, newStart: number, lineCount: number) => { + const count = Math.max(0, lineCount); + const selectedStart = selectedSide === "left" ? oldStart : newStart; + const offset = lineNumber - selectedStart; + return offset >= 0 && offset < count ? rowIndex + offset : -1; + }; + + for (const hunk of fileDiff.hunks) { + if (includeExpandedContext) { + const oldContextEnd = hunk.deletionStart + (hunk.deletionCount === 0 ? 1 : 0); + const newContextEnd = hunk.additionStart + (hunk.additionCount === 0 ? 1 : 0); + const contextLines = Math.min( + oldContextEnd - oldContextStart, + newContextEnd - newContextStart, + ); + const contextIndex = findContextIndex(oldContextStart, newContextStart, contextLines); + if (contextIndex >= 0) return contextIndex; + rowIndex += Math.max(0, contextLines); + } + + let oldLineNumber = hunk.deletionStart; + let newLineNumber = hunk.additionStart; + for (const segment of hunk.hunkContent) { + if (segment.type === "context") { + const contextIndex = findContextIndex(oldLineNumber, newLineNumber, segment.lines); + if (contextIndex >= 0) return contextIndex; + rowIndex += segment.lines; + oldLineNumber += segment.lines; + newLineNumber += segment.lines; + continue; + } + + if ( + selectedSide === "left" && + lineNumber >= oldLineNumber && + lineNumber < oldLineNumber + segment.deletions + ) { + return rowIndex + lineNumber - oldLineNumber; + } + rowIndex += segment.deletions; + oldLineNumber += segment.deletions; + + if ( + selectedSide === "right" && + lineNumber >= newLineNumber && + lineNumber < newLineNumber + segment.additions + ) { + return rowIndex + lineNumber - newLineNumber; + } + rowIndex += segment.additions; + newLineNumber += segment.additions; + } + + oldContextStart = hunk.deletionStart + hunk.deletionCount; + newContextStart = hunk.additionStart + hunk.additionCount; + if (hunk.deletionCount === 0) oldContextStart += 1; + if (hunk.additionCount === 0) newContextStart += 1; + } + + if (!includeExpandedContext) return -1; + const trailingLines = Math.min( + fileDiff.deletionLines.length - oldContextStart + 1, + fileDiff.additionLines.length - newContextStart + 1, + ); + return findContextIndex(oldContextStart, newContextStart, trailingLines); + }; + + const selectedSide = side === "deletions" ? "left" : "right"; + const preferredIndex = findOnSide(selectedSide); + return preferredIndex >= 0 + ? preferredIndex + : findOnSide(selectedSide === "left" ? "right" : "left"); +} + +/** Resolve the host-facing coordinates of a line selected in the diff viewer. */ +export function resolveDiffReviewPosition( + fileDiff: FileDiffMetadata, + lineNumber: number, + side: SelectionSide | undefined, +): PullRequestReviewPosition | null { + const lineIndex = findDiffReviewLineIndex(fileDiff, lineNumber, side); + if (lineIndex < 0) return null; + const line = buildDiffReviewLines(fileDiff, !fileDiff.isPartial, { + startIndex: lineIndex, + endIndex: lineIndex, + })[0]; + if (line === undefined) return null; + + switch (line.change) { + case "add": + return line.newLineNumber === null ? null : { kind: "added", newLine: line.newLineNumber }; + case "delete": + return line.oldLineNumber === null ? null : { kind: "deleted", oldLine: line.oldLineNumber }; + case "context": + return line.oldLineNumber === null || line.newLineNumber === null + ? null + : { + kind: "context", + oldLine: line.oldLineNumber, + newLine: line.newLineNumber, + side: side === "deletions" ? "left" : "right", + }; + } } function getDiffRange( @@ -416,18 +588,27 @@ export function buildDiffReviewComment(input: { range: SelectedLineRange; text: string; }): ReviewCommentContext | null { - const lines = buildDiffReviewLines(input.fileDiff); - const startIndex = findDiffReviewLineIndex(lines, input.range.start, input.range.side); + const includeExpandedContext = !input.fileDiff.isPartial; + const startIndex = findDiffReviewLineIndex( + input.fileDiff, + input.range.start, + input.range.side, + includeExpandedContext, + ); const endIndex = findDiffReviewLineIndex( - lines, + input.fileDiff, input.range.end, input.range.endSide ?? input.range.side, + includeExpandedContext, ); if (startIndex < 0 || endIndex < 0) return null; const normalizedStartIndex = Math.min(startIndex, endIndex); const normalizedEndIndex = Math.max(startIndex, endIndex); - const selectedLines = lines.slice(normalizedStartIndex, normalizedEndIndex + 1); + const selectedLines = buildDiffReviewLines(input.fileDiff, includeExpandedContext, { + startIndex: normalizedStartIndex, + endIndex: normalizedEndIndex, + }); const oldRange = getDiffRange(selectedLines, "oldLineNumber"); const newRange = getDiffRange(selectedLines, "newLineNumber"); @@ -445,6 +626,12 @@ export function buildDiffReviewComment(input: { ...selectedLines.map((line) => `${getDiffChangeMarker(line.change)}${line.content}`), ].join("\n"), fenceLanguage: "diff", + selection: { + start: input.range.start, + side: input.range.side ?? "additions", + end: input.range.end, + endSide: input.range.endSide ?? input.range.side ?? "additions", + }, }; } diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index d1b2ba705..dea49ea8f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -856,6 +856,26 @@ export const PullRequestCommentUpdateInput = Schema.Struct({ }); export type PullRequestCommentUpdateInput = typeof PullRequestCommentUpdateInput.Type; +/** The coordinates of one line in a pull request diff. */ +export const PullRequestReviewPosition = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("added"), + newLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("deleted"), + oldLine: PositiveInt, + }), + Schema.Struct({ + kind: Schema.Literal("context"), + oldLine: PositiveInt, + newLine: PositiveInt, + /** Which copy of an unchanged line the reviewer selected in a split diff. */ + side: PullRequestDiffSide, + }), +]); +export type PullRequestReviewPosition = typeof PullRequestReviewPosition.Type; + /** One remark in a review that has not been sent yet, anchored to a line of the diff. */ export const PullRequestReviewCommentDraft = Schema.Struct({ path: TrimmedNonEmptyString, @@ -865,8 +885,7 @@ export const PullRequestReviewCommentDraft = Schema.Struct({ * the hosts that address a comment by one path ignore this. */ oldPath: Schema.optional(TrimmedNonEmptyString), - line: PositiveInt, - side: PullRequestDiffSide, + position: PullRequestReviewPosition, body: CommentBody, }); export type PullRequestReviewCommentDraft = typeof PullRequestReviewCommentDraft.Type; From 25dd6303ecb56744cba3b87241ffbb162cb4bc2d Mon Sep 17 00:00:00 2001 From: JJ <93147993+hey-jj@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:41:51 -0600 Subject: [PATCH 27/99] fix(mobile): recover the QR pairing scanner when camera access is denied (#6487) (cherry picked from commit 3bc4fdf05b6b748a7b506c81dc125f3504f35278) --- .../connection/ConnectionsNewRouteScreen.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx index 37d53cbd8..7fa3c691b 100644 --- a/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx @@ -3,7 +3,7 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/Stac import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -95,9 +95,21 @@ export function ConnectionsNewRouteScreen({ return; } + if (permission.canAskAgain) { + Alert.alert( + "Camera access needed", + "Allow camera access to scan an environment pairing QR code.", + ); + return; + } + Alert.alert( "Camera access needed", - "Allow camera access to scan an environment pairing QR code.", + "Camera access was denied for this app. Open Settings to enable it.", + [ + { text: "Cancel", style: "cancel" }, + { text: "Open Settings", onPress: () => void Linking.openSettings() }, + ], ); }, [cameraPermission?.granted, requestCameraPermission]); From 99782f2b7666262e81d84afcf5a382667176da97 Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Sat, 15 Aug 2026 13:42:08 +0200 Subject: [PATCH 28/99] fix(web): keep a long path from running under the folder picker button (#4823) Co-authored-by: Sy-D <8460326+Sy-D@users.noreply.github.com> Co-authored-by: Claude Opus 5 Co-authored-by: Julius Marminge Co-authored-by: codex (cherry picked from commit a38cac81d82b82a6967eaf8cb90ed2770c514f3c) --- .../components/CommandPalette.logic.test.ts | 30 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 13 ++++++++ apps/web/src/components/CommandPalette.tsx | 12 +++++--- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index e2d7687fe..cc3fe9e2b 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + browseInputEndPaddingClass, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, @@ -10,6 +11,35 @@ import { type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("browseInputEndPaddingClass", () => { + it("reserves the widest space for the create action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: true, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-38"); + }); + + it("reserves space for the wider highlighted-item shortcut", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: true, + }), + ).toContain("pe-30"); + }); + + it("keeps the compact reserve for the normal add action", () => { + expect( + browseInputEndPaddingClass({ + willCreateProjectPath: false, + hasHighlightedBrowseItem: false, + }), + ).toContain("pe-24"); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 07e0e520d..95d7a91b7 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,19 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +export function browseInputEndPaddingClass(input: { + readonly willCreateProjectPath: boolean; + readonly hasHighlightedBrowseItem: boolean; +}): string { + if (input.willCreateProjectPath) { + return "*:data-[slot=autocomplete-input]:pe-38!"; + } + if (input.hasHighlightedBrowseItem) { + return "*:data-[slot=autocomplete-input]:pe-30!"; + } + return "*:data-[slot=autocomplete-input]:pe-24!"; +} + /** * The global search overlay hosts three mutually exclusive surfaces: the * command palette (⌘K), the project file picker (⌘P), and project content diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2bad73d49..5ab4ccb8c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -100,6 +100,7 @@ import { } from "../wslPaths"; import { ADDON_ICON_CLASS, + browseInputEndPaddingClass, buildBrowseGroups, buildProjectActionItems, buildRootGroups, @@ -2386,13 +2387,16 @@ function OpenCommandPaletteDialog(props: { footerTrailing={footerTrailing} inputAccessory={inputAccessory} inputProps={{ + // The submit button is absolutely positioned over the field, so the + // inner input must reserve enough room for the full action label. className: addProjectCloneFlow?.step === "repository" - ? "pe-32" + ? "*:data-[slot=autocomplete-input]:pe-32!" : isBrowsing - ? willCreateProjectPath - ? "pe-36" - : "pe-16" + ? browseInputEndPaddingClass({ + willCreateProjectPath, + hasHighlightedBrowseItem, + }) : undefined, placeholder: inputPlaceholder, wrapperClassName: isSubmenu From 61bdade7e9e0c5b90e3eed95546bd61f3225e9ef Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:44:37 +0200 Subject: [PATCH 29/99] fix(terminal): right-click paste works in the terminal (#5240) (cherry picked from commit 270489b887420db3319898ab4046516e4c457711) --- .../src/components/ThreadTerminalDrawer.tsx | 189 +++++++++++++++--- apps/web/src/hooks/useCopyToClipboard.ts | 44 ++++ apps/web/src/terminal/ghostty/surface.ts | 30 +++ 3 files changed, 230 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 1266e5ed7..cf2adaca2 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { XIcon, } from "lucide-react"; import { + type ContextMenuItem, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -32,7 +33,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; -import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { @@ -255,6 +256,49 @@ export function terminalSelectionLineRange(position: { }; } +export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; + +/** Post-selection popup: just the two selection actions, always enabled. */ +export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { + return [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ]; +} + +/** + * Right-click menu for the terminal canvas: the selection actions (disabled + * until a selection exists) plus Paste. Paste is always offered: the browser + * (and Electron's default editing menu) can only paste into an editable + * element, so a canvas terminal never gets a usable entry from them. + */ +export function terminalContextMenuItems(options: { + hasSelection: boolean; +}): ContextMenuItem[] { + return [ + ...terminalSelectionMenuItems().map((item) => ({ + ...item, + disabled: !options.hasSelection, + })), + { id: "paste", label: "Paste" }, + ]; +} + +/** + * An empty selection change may only cancel a selection-action flow that is + * still current: a pending popup timer, or an open popup whose request id has + * not been superseded. A popup already superseded by a right-click keeps its + * menu promise unsettled for a moment; treating it as active would cancel the + * newer context-menu flow instead. + */ +export function shouldClearTerminalSelectionAction(options: { + timerPending: boolean; + openMenuRequestId: number | null; + currentRequestId: number; +}): boolean { + return options.timerPending || options.openMenuRequestId === options.currentRequestId; +} + export function shouldHandleTerminalExit( current: TerminalSessionState["status"], synchronized: TerminalSessionState["status"], @@ -328,7 +372,10 @@ export function TerminalViewport({ const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); - const selectionActionMenuOpenRef = useRef(false); + // Holds the request id of the selection popup currently on screen, so a + // popup that was superseded (but whose menu promise has not settled yet) + // cannot be mistaken for the active flow. + const openSelectionMenuRequestIdRef = useRef(null); const selectionActionTimerRef = useRef(null); const keybindingsRef = useRef(keybindings); const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); @@ -443,6 +490,12 @@ export function TerminalViewport({ onSelectionChange: () => handleSelectionChange(), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), + // The surface listens from construction, so a right-click can land + // while `create` is still awaiting WASM — before the handler below it + // exists. The ref is only assigned once that setup has run. + onContextMenu: (event) => { + if (terminalRef.current) void showTerminalContextMenu(event); + }, }; const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); if (cancelled) { @@ -518,12 +571,98 @@ export function TerminalViewport({ }; }; + const addSelectionToChat = (selection: TerminalContextSelection) => { + handleAddTerminalContext(selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + }; + + // A selection-action flow that was superseded while its async work ran + // must go silent: no error message, no focus steal. + const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => { + if (requestId !== selectionActionRequestIdRef.current) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallback); + } + }; + + const focusIfCurrent = (requestId: number) => { + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); + } + }; + + const copySelection = async (text: string, requestId: number) => { + try { + await writeTextToClipboard(text, "terminal selection"); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to copy terminal selection"); + } + focusIfCurrent(requestId); + }; + + const pasteFromClipboard = async (requestId: number) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + try { + // The surface owns the read so it can claim the paste race before it + // starts: a paste shortcut fired while the menu read is in flight + // supersedes this paste instead of landing alongside it. + await activeTerminal.pasteFromClipboard( + () => readTextFromClipboard("terminal input"), + () => requestId === selectionActionRequestIdRef.current, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to read the clipboard"); + return; + } + focusIfCurrent(requestId); + }; + + const showTerminalContextMenu = async (event: MouseEvent) => { + if (!localApi || !terminalRef.current) return; + // Own the gesture before anything async: leaving the default alive lets + // the browser (or Electron's editing menu) answer with a Paste entry + // that is permanently disabled over the terminal canvas. + event.preventDefault(); + // A right-click supersedes a selection popup that is pending or open. + clearSelectionAction(); + const selectionAction = readSelectionAction(); + const requestId = selectionActionRequestIdRef.current; + let clicked: TerminalContextMenuAction | null; + try { + clicked = await localApi.contextMenu.show( + terminalContextMenuItems({ hasSelection: selectionAction !== null }), + { x: event.clientX, y: event.clientY }, + ); + } catch (error) { + reportIfCurrent(requestId, error, "Unable to open the terminal context menu"); + focusIfCurrent(requestId); + return; + } + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + if (selectionAction) addSelectionToChat(selectionAction.selection); + return; + case "copy": + if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); + return; + case "paste": + await pasteFromClipboard(requestId); + return; + } + }; + const showSelectionAction = async () => { if (!localApi) { clearSelectionAction(); return; } - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { return; } const nextAction = readSelectionAction(); @@ -532,45 +671,23 @@ export function TerminalViewport({ return; } const requestId = ++selectionActionRequestIdRef.current; - selectionActionMenuOpenRef.current = true; + openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], - nextAction.position, - ) + .show(terminalSelectionMenuItems(), nextAction.position) .finally(() => { - selectionActionMenuOpenRef.current = false; + if (openSelectionMenuRequestIdRef.current === requestId) { + openSelectionMenuRequestIdRef.current = null; + } }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } switch (clicked) { case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + addSelectionToChat(nextAction.selection); return; case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; - } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); - } + await copySelection(nextAction.clipboardText, requestId); return; } }; @@ -684,11 +801,17 @@ export function TerminalViewport({ if (terminalRef.current?.hasSelection()) { return; } + const shouldClear = shouldClearTerminalSelectionAction({ + timerPending: selectionActionTimerRef.current !== null, + openMenuRequestId: openSelectionMenuRequestIdRef.current, + currentRequestId: selectionActionRequestIdRef.current, + }); + if (!shouldClear) return; clearSelectionAction(); // A copy shortcut that clears the selection (Ctrl+C) must also close // the context menu that appears with the selection, but a clear that // never opened a menu must not dismiss an unrelated one. - if (selectionActionMenuOpenRef.current) { + if (openSelectionMenuRequestIdRef.current !== null) { void localApi?.contextMenu.close(); } } diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 0129f2d65..ef66410f7 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -24,6 +24,29 @@ export class ClipboardWriteError extends Schema.TaggedErrorClass()( + "ClipboardReadUnavailableError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `Clipboard API is unavailable while reading ${this.target}.`; + } +} + +export class ClipboardReadError extends Schema.TaggedErrorClass()( + "ClipboardReadError", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.target} from the clipboard.`; + } +} + export async function writeTextToClipboard(value: string, target = "text") { if ( typeof window === "undefined" || @@ -48,6 +71,27 @@ export async function writeTextToClipboard(value: string, target = "text") { } } +export async function readTextFromClipboard(target = "text"): Promise { + if ( + typeof window === "undefined" || + typeof navigator === "undefined" || + !navigator.clipboard?.readText + ) { + throw new ClipboardReadUnavailableError({ + target, + }); + } + + try { + return await navigator.clipboard.readText(); + } catch (cause) { + throw new ClipboardReadError({ + target, + cause, + }); + } +} + export function useCopyToClipboard({ timeout = 2000, target = "text", diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index d4ae94a3e..c4590e8db 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -465,6 +465,12 @@ export interface GhosttyTerminalSurfaceOptions { readonly onSelectionChange: () => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; + /** + * A right-click the running application did not claim through mouse + * reporting. The host owns the menu, so it also owns preventing the browser + * default — whose Paste entry can never reach a canvas terminal. + */ + readonly onContextMenu?: (event: MouseEvent) => void; } export class GhosttyTerminalSurface { @@ -801,6 +807,28 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** + * Pastes clipboard text read by the host (context menu) with the same + * bracketed-paste encoding as a native paste event. The read joins the same + * race the paste shortcut uses — the token is claimed before it starts — so + * a shortcut or native paste arriving during the read supersedes this one + * instead of both reaching the shell. + */ + async pasteFromClipboard( + readText: () => Promise, + isCurrent: () => boolean = () => true, + ): Promise { + const token = ++this.pasteShortcutToken; + const text = await readText(); + if (this.disposed || this.pasteShortcutToken !== token || !isCurrent()) return; + // As in every paste path, delivering bumps the token so a clipboard read + // still in flight cannot land after this text reaches the shell. + this.pasteShortcutToken += 1; + if (text.length === 0) return; + const encoded = this.core.encodePaste(text); + if (encoded.length > 0) this.options.onData(encoded); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1373,7 +1401,9 @@ export class GhosttyTerminalSurface { private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { From 842493abeaf8fd3c59f0e4b48c7c9dcff80f7eb8 Mon Sep 17 00:00:00 2001 From: Daniel Vernon Date: Sat, 15 Aug 2026 13:00:18 +0100 Subject: [PATCH 30/99] fix(mobile): explain iOS-only settings on Android (#4981) (cherry picked from commit 4db50757c0b618293997a7f81bcfe30b68356969) --- .../SettingsRouteScreen.logic.test.ts | 19 +++++++++++++++++++ .../settings/SettingsRouteScreen.logic.ts | 8 ++++++++ .../features/settings/SettingsRouteScreen.tsx | 6 ++++++ .../settings/components/SettingsSwitchRow.tsx | 8 +++++++- 4 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts create mode 100644 apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts new file mode 100644 index 000000000..aec583d67 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; + +describe("resolveAgentAwarenessPlatformPresentation", () => { + it("explains that agent awareness settings are unavailable on Android", () => { + expect(resolveAgentAwarenessPlatformPresentation("android")).toEqual({ + supported: false, + subtitle: "iOS only", + }); + }); + + it("leaves supported iOS settings unchanged", () => { + expect(resolveAgentAwarenessPlatformPresentation("ios")).toEqual({ + supported: true, + subtitle: undefined, + }); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts new file mode 100644 index 000000000..94fa5965e --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.logic.ts @@ -0,0 +1,8 @@ +export function resolveAgentAwarenessPlatformPresentation(platform: string): { + readonly supported: boolean; + readonly subtitle: string | undefined; +} { + return platform === "ios" + ? { supported: true, subtitle: undefined } + : { supported: false, subtitle: "iOS only" }; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index fd57a4eea..c552b1c89 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; +import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -144,6 +145,7 @@ function ConfiguredSettingsRouteScreen() { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); + const agentAwarenessPlatform = resolveAgentAwarenessPlatformPresentation(Platform.OS); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); @@ -473,10 +475,12 @@ function ConfiguredSettingsRouteScreen() { icon="bell.badge" label="Device Notifications" disabled={ + !agentAwarenessPlatform.supported || !agentAwarenessPushAvailable || notificationStatus === "checking" || notificationStatus === "unsupported" } + subtitle={agentAwarenessPlatform.subtitle} // Only reads as on when this device is actually registered with the // relay; otherwise notifications cannot be delivered regardless of // the local iOS permission. @@ -487,6 +491,7 @@ function ConfiguredSettingsRouteScreen() { /> void; }) { @@ -27,7 +28,12 @@ export function SettingsSwitchRow(props: { } > - {props.label} + + {props.label} + {props.subtitle ? ( + {props.subtitle} + ) : null} + Date: Sat, 15 Aug 2026 17:30:51 +0530 Subject: [PATCH 31/99] fix(web): stop counting a workflow coordinator as a working agent (#6672) (cherry picked from commit 9bdd91293316038d6a50f8bb19afcd73c25ad102) --- .../src/state/subagentRuntime.test.ts | 42 +++++++++++++++++-- .../src/state/subagentRuntime.ts | 11 ++--- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/client-runtime/src/state/subagentRuntime.test.ts b/packages/client-runtime/src/state/subagentRuntime.test.ts index ac74c1afc..482a7b491 100644 --- a/packages/client-runtime/src/state/subagentRuntime.test.ts +++ b/packages/client-runtime/src/state/subagentRuntime.test.ts @@ -449,13 +449,49 @@ describe("deriveAgentPanelModel", () => { it("counts idle deliberately and waiting as active", () => { const model = deriveAgentPanelModel({ agents: roster }); expect(model.idleCount).toBe(1); - // wf-1 coordinator + member 1 running. - expect(model.runningCount).toBeGreaterThanOrEqual(1); + // Member 1 is running; the wf-1 coordinator is a container, not a worker. + expect(model.runningCount).toBe(1); + // Every agent lands in exactly one bucket, except coordinators that stand + // in for their members. expect(model.idleCount + model.runningCount + model.waitingCount + model.settledCount).toBe( - roster.length, + roster.length - 1, ); }); + it("omits a workflow coordinator from the working-agent count", () => { + const model = deriveAgentPanelModel({ agents: roster }); + // One member still running plus one idle direct spawn. The coordinator + // reports running for the whole workflow and must not inflate the banner. + expect(model.liveCount).toBe(1); + }); + + it("omits a finished workflow coordinator from the settled count", () => { + const finished = fold([ + activity("task.started", { taskId: "wf-2", taskType: "local_workflow", title: "sweep" }), + activity("task.progress", { + taskId: "wf-2:wf:0", + title: "sweep:a", + status: "completed", + parentAgentId: "wf-2", + agentIndex: 0, + phaseIndex: 0, + }), + activity("task.completed", { + taskId: "wf-2:wf:0", + status: "completed", + parentAgentId: "wf-2", + }), + activity("task.completed", { taskId: "wf-2", status: "completed" }), + ]); + + const model = deriveAgentPanelModel({ agents: finished }); + + // Only the member settled. The coordinator stands in for it, so counting + // both would report two finished agents where one ran. + expect(model.settledCount).toBe(1); + expect(model.liveCount).toBe(0); + }); + it("keeps direct spawns in first-seen order as their activity changes", () => { const directRoster = fold([ activity("task.started", { taskId: "direct-a", title: "First" }, "2026-08-01T11:00:00.000Z"), diff --git a/packages/client-runtime/src/state/subagentRuntime.ts b/packages/client-runtime/src/state/subagentRuntime.ts index 783ef6215..39fbc7a29 100644 --- a/packages/client-runtime/src/state/subagentRuntime.ts +++ b/packages/client-runtime/src/state/subagentRuntime.ts @@ -937,15 +937,16 @@ export function deriveAgentPanelModel({ let settledCount = 0; let totalTokens = 0; for (const agent of source) { + // A workflow coordinator with members is a container for those members, not + // work of its own: it reports running for the whole run and aggregates their + // usage upstream in some providers. Counting it would report one more agent + // working than there are, and double count tokens. + if (agent.kind === "workflow" && (members.get(agent.id) ?? []).length > 0) continue; if (agent.status === "running" || agent.status === "pending") runningCount += 1; else if (agent.status === "waiting") waitingCount += 1; else if (agent.status === "idle") idleCount += 1; else settledCount += 1; - // Workflow coordinators aggregate member usage upstream in some providers; - // avoid double counting by only summing leaf agents when members exist. - if (agent.kind !== "workflow" || (members.get(agent.id) ?? []).length === 0) { - totalTokens += agent.usage?.totalTokens ?? 0; - } + totalTokens += agent.usage?.totalTokens ?? 0; } return { From 461796f96bdf24232196c00423608312c055aced Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:00:53 -0400 Subject: [PATCH 32/99] fix(web): keep floating preview anchored after panel closes (#6547) (cherry picked from commit 6e6d1b49412d064ccbde7daae2e287f9b62efd7d) --- .../preview/ThreadPreviewMiniPlayer.tsx | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 3e7c46ef0..2bdba1afe 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -2,7 +2,7 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { PanelRightIcon, PictureInPicture2, XIcon } from "lucide-react"; -import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef } from "react"; +import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useRef, useState } from "react"; import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; @@ -17,6 +17,7 @@ import { clampPreviewMiniPlayerPosition, clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, + PREVIEW_MINI_PLAYER_EDGE_GAP, } from "./previewMiniPlayerLayout"; interface DragState { @@ -31,6 +32,8 @@ interface ResizeState { readonly pointerId: number; readonly pointerX: number; readonly pointerY: number; + readonly playerX: number; + readonly playerY: number; readonly width: number; readonly height: number; } @@ -45,6 +48,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const rootRef = useRef(null); const dragRef = useRef(null); const resizeRef = useRef(null); + const [defaultLayoutVersion, setDefaultLayoutVersion] = useState(""); const miniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef), ); @@ -91,8 +95,12 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props bottomInset, ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); + if (!position) { + setDefaultLayoutVersion(`${parent.clientWidth}:${parent.clientHeight}`); + return; + } const next = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + position, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -159,11 +167,16 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const root = rootRef.current; - if (!root) return; + const parent = root?.offsetParent; + if (!root || !(parent instanceof HTMLElement)) return; + const rootRect = root.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); resizeRef.current = { pointerId: event.pointerId, pointerX: event.clientX, pointerY: event.clientY, + playerX: rootRect.left - parentRect.left, + playerY: rootRect.top - parentRect.top, width: root.offsetWidth, height: root.offsetHeight, }; @@ -194,7 +207,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props ); usePreviewMiniPlayerStore.getState().resize(threadRef, tabId, nextSize); const nextPosition = clampPreviewMiniPlayerPosition( - position ?? { x: root.offsetLeft, y: root.offsetTop }, + { x: resize.playerX, y: resize.playerY }, { width: parent.clientWidth, height: parent.clientHeight }, nextSize, bottomInset, @@ -222,8 +235,8 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props position ? { left: position.x, top: position.y, width: size.width, height: size.height } : { - right: 16, - top: 16, + right: PREVIEW_MINI_PLAYER_EDGE_GAP, + top: PREVIEW_MINI_PLAYER_EDGE_GAP, width: size.width, height: size.height, } @@ -290,7 +303,11 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props visible={Boolean(desktopOverlay?.hasWebContents)} cornerRadius={12} fitSourceContent - layoutVersion={position ? `${position.x}:${position.y}` : `initial:${bottomInset}`} + layoutVersion={ + position + ? `${position.x}:${position.y}` + : `initial:${bottomInset}:${defaultLayoutVersion}` + } className="absolute inset-0" />
    From 7ba5cf273b4ce840c7ed60482b4300778186a9f6 Mon Sep 17 00:00:00 2001 From: Torben Wetter Date: Sat, 15 Aug 2026 14:01:08 +0200 Subject: [PATCH 33/99] fix(web): unstick /connect after in-modal sign-in by redirecting to the authorize endpoint (#5133) (cherry picked from commit a7c5ad5db167b3a172ccb26408b0638c99b2a459) --- apps/web/src/cloud/connectCliAuth.test.ts | 24 +++++++++++++++ apps/web/src/cloud/connectCliAuth.ts | 17 +++++++++++ .../src/components/clerk/authRedirect.test.ts | 5 +++- apps/web/src/components/clerk/authRedirect.ts | 4 ++- .../cloud/ConnectCliAuthSurface.tsx | 29 +++++++++++++------ 5 files changed, 68 insertions(+), 11 deletions(-) diff --git a/apps/web/src/cloud/connectCliAuth.test.ts b/apps/web/src/cloud/connectCliAuth.test.ts index 59b443a49..3d41c4166 100644 --- a/apps/web/src/cloud/connectCliAuth.test.ts +++ b/apps/web/src/cloud/connectCliAuth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, hasConnectCliAuthConfig, readConnectCliCallbackResult, } from "./connectCliAuth"; @@ -69,6 +70,29 @@ describe("connectCliAuth", () => { ).toBeNull(); }); + it("sends the sign-in redirect to the authorize endpoint, not back to /connect", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + vi.stubEnv("VITE_CLERK_CLI_OAUTH_CLIENT_ID", "oauthapp_123"); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + const redirectUrl = connectCliSignInRedirectUrl( + { state: "state-1", challenge: "challenge-1" }, + connectUrl, + ); + + expect(redirectUrl).not.toBe(connectUrl); + expect(new URL(redirectUrl).pathname).toBe("/oauth/authorize"); + }); + + it("falls back to the current URL when the authorize URL cannot be built", () => { + vi.stubEnv("VITE_CLERK_PUBLISHABLE_KEY", TEST_PUBLISHABLE_KEY); + + const connectUrl = "https://app.t3.codes/connect#state=state-1&challenge=challenge-1"; + expect( + connectCliSignInRedirectUrl({ state: "state-1", challenge: "challenge-1" }, connectUrl), + ).toBe(connectUrl); + }); + it("reads the code and state Clerk echoes back to the callback", () => { expect( readConnectCliCallbackResult( diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 969215d97..815715da2 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -60,6 +60,23 @@ export function buildConnectCliClerkAuthorizeUrl(request: ConnectAuthorizeReques }); } +/** + * Where Clerk sends the browser once the sign-in modal on /connect completes. + * It has to be the authorize endpoint rather than this page: /connect carries + * the CLI request in its fragment, so navigating back to the same URL is a + * same-document fragment navigation the browser never reloads — and Clerk + * treats any post-sign-in navigation as a page unload and skips the state emit + * that would otherwise re-render the surface, so the session never arrives + * either. Falls back to the current URL when the authorize URL cannot be + * built, which only happens on a deployment without the CLI OAuth config. + */ +export function connectCliSignInRedirectUrl( + request: ConnectAuthorizeRequest, + currentHref: string, +): string { + return buildConnectCliClerkAuthorizeUrl(request) ?? currentHref; +} + export function rememberConnectCliAuthState(state: string): void { try { window.sessionStorage.setItem(CONNECT_CLI_AUTH_STATE_STORAGE_KEY, state); diff --git a/apps/web/src/components/clerk/authRedirect.test.ts b/apps/web/src/components/clerk/authRedirect.test.ts index 140474120..e948d1d9c 100644 --- a/apps/web/src/components/clerk/authRedirect.test.ts +++ b/apps/web/src/components/clerk/authRedirect.test.ts @@ -5,7 +5,10 @@ import { resolveClerkSignInProps } from "./authRedirect"; describe("resolveClerkSignInProps", () => { it("returns to the current browser URL on the web", () => { const href = "https://app.t3.codes/connect?state=state-1#details"; - expect(resolveClerkSignInProps(href, false)).toEqual({ forceRedirectUrl: href }); + expect(resolveClerkSignInProps(href, false)).toEqual({ + forceRedirectUrl: href, + signUpForceRedirectUrl: href, + }); }); it("removes a Clerk virtual pathname and callback params while preserving the desktop route", () => { diff --git a/apps/web/src/components/clerk/authRedirect.ts b/apps/web/src/components/clerk/authRedirect.ts index 251c5ee36..e0b07241c 100644 --- a/apps/web/src/components/clerk/authRedirect.ts +++ b/apps/web/src/components/clerk/authRedirect.ts @@ -15,5 +15,7 @@ export function resolveClerkSignInProps(href: string, isElectron: boolean): Cler signUpForceRedirectUrl: redirectUrl.toString(), }; } - return { forceRedirectUrl: href }; + // The sign-in modal can switch to sign-up, which follows its own redirect + // target; without one Clerk falls back to the URL the modal was opened from. + return { forceRedirectUrl: href, signUpForceRedirectUrl: href }; } diff --git a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx index 2b23184fa..85a418add 100644 --- a/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx +++ b/apps/web/src/components/cloud/ConnectCliAuthSurface.tsx @@ -1,9 +1,10 @@ import { useAuth, useClerk, useUser } from "@clerk/react"; import { encodeConnectAuthCode, readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { buildConnectCliClerkAuthorizeUrl, + connectCliSignInRedirectUrl, readConnectCliAuthState, readConnectCliCallbackResult, rememberConnectCliAuthState, @@ -56,6 +57,21 @@ export function ConnectCliAuthorizeSurface() { const signInOpened = useRef(false); const redirecting = useRef(false); + const openSignIn = useCallback(() => { + if (!request) { + return; + } + // Clerk redirects to the authorize endpoint itself once sign-in completes, + // so the callback's state check has to be armed before handing off. + rememberConnectCliAuthState(request.state); + clerk.openSignIn( + resolveClerkSignInProps( + connectCliSignInRedirectUrl(request, window.location.href), + isElectron, + ), + ); + }, [clerk, request]); + useEffect(() => { if (!request || !isLoaded || redirecting.current) { return; @@ -63,7 +79,7 @@ export function ConnectCliAuthorizeSurface() { if (!isSignedIn) { if (!signInOpened.current) { signInOpened.current = true; - clerk.openSignIn(resolveClerkSignInProps(window.location.href, isElectron)); + openSignIn(); } return; } @@ -74,7 +90,7 @@ export function ConnectCliAuthorizeSurface() { redirecting.current = true; rememberConnectCliAuthState(request.state); window.location.assign(authorizeUrl); - }, [clerk, isLoaded, isSignedIn, request]); + }, [isLoaded, isSignedIn, openSignIn, request]); if (!request) { return ( @@ -101,12 +117,7 @@ export function ConnectCliAuthorizeSurface() { /> {isLoaded && !isSignedIn ? (
    -
    From dbd68fa2862c875b43fdf1f26003e6517f951ab2 Mon Sep 17 00:00:00 2001 From: BootesVoid <78485654+AMohamedAakhil@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:31:11 +0530 Subject: [PATCH 34/99] fix(web): keep send reachable while a turn is running on mobile (#4781) Co-authored-by: AMohamedAakhil Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Julius Marminge Co-authored-by: codex (cherry picked from commit 7afa184a99b266d466cc9517c147a75c3d839ad7) --- apps/web/src/components/chat/ChatComposer.tsx | 3 + ...est.ts => ComposerPrimaryActions.test.tsx} | 45 ++++++++ .../chat/ComposerPrimaryActions.tsx | 100 ++++++++++-------- 3 files changed, 106 insertions(+), 42 deletions(-) rename apps/web/src/components/chat/{ComposerPrimaryActions.test.ts => ComposerPrimaryActions.test.tsx} (80%) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ba36d7a0e..e818b0b6f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -513,6 +513,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isEnvironmentUnavailable: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -547,6 +548,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( isPreparingWorktree={props.isPreparingWorktree} hasSendableContent={props.hasSendableContent} preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false} + showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} onImplementPlanInNewThread={props.onImplementPlanInNewThread} @@ -4133,6 +4135,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isPreparingWorktree={isPreparingWorktree} hasSendableContent={composerSendState.hasSendableContent} preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx similarity index 80% rename from apps/web/src/components/chat/ComposerPrimaryActions.test.ts rename to apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 44d885a4d..a700edde1 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -69,6 +69,28 @@ function renderStandaloneStop() { ); } +function renderRunningActions(showSendWhileRunning: boolean, hasSendableContent: boolean) { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: true, + showPlanFollowUpPrompt: false, + promptHasText: hasSendableContent, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent, + showSendWhileRunning, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + function renderSendButton() { return renderToStaticMarkup( createElement(ComposerPrimaryActions, { @@ -221,4 +243,27 @@ describe("ComposerPrimaryActions", () => { expect(markup).not.toContain("stage-nightly"); expect(markup).toContain("bg-message-action text-message-action-foreground"); }); + + it("only renders stop while running when Enter-to-send is available", () => { + const markup = renderRunningActions(false, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); + + it("renders send alongside stop while running when Enter-to-send is unavailable", () => { + const markup = renderRunningActions(true, true); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).toContain('aria-label="Send message"'); + expect(markup).toContain('type="submit"'); + expect(markup).toContain("size-9 sm:size-8"); + }); + + it("keeps stop as the only action while running with an empty composer", () => { + const markup = renderRunningActions(true, false); + + expect(markup).toContain('aria-label="Stop generation"'); + expect(markup).not.toContain('aria-label="Send message"'); + }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index a8705410d..e52eb287a 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -30,6 +30,9 @@ interface ComposerPrimaryActionsProps { isPreparingWorktree: boolean; hasSendableContent: boolean; preserveComposerFocusOnPointerDown?: boolean; + /** Enter-to-send is disabled on mobile viewports, where stop would otherwise + * be the only primary action and a running turn could not be steered. */ + showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -72,6 +75,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ isPreparingWorktree, hasSendableContent, preserveComposerFocusOnPointerDown = false, + showSendWhileRunning = false, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -90,7 +94,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ type="button" className={cn( "flex cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 inset-shadow-[0_1px_--theme(--color-white/16%)] transition-all duration-150 hover:bg-destructive hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none", - insidePendingAction ? "size-8 sm:size-7" : "size-8 sm:h-8 sm:w-8", + insidePendingAction + ? "size-8 sm:size-7" + : showSendWhileRunning && hasSendableContent + ? "size-9 sm:size-8" + : "size-8 sm:h-8 sm:w-8", )} {...pointerFocusProps} onClick={onInterrupt} @@ -157,46 +165,6 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - if (isRunning) { - return ( -
    - {renderStopGenerationButton(false)} - {canQueueFollowUp ? ( - - ) : null} -
    - ); - } - if (showPlanFollowUpPrompt) { if (promptHasText) { return ( @@ -254,7 +222,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); } - return ( + const sendButton = ( + ) : showSendWhileRunning && hasSendableContent ? ( + sendButton + ) : null} +
    + ); }); From 0429bb36fb2f8f916146f08f7282d21277b3716b Mon Sep 17 00:00:00 2001 From: mohammed shazeb Date: Sat, 15 Aug 2026 17:31:26 +0530 Subject: [PATCH 35/99] fix(web): reject unsupported composer image types at attach time (#6574) (cherry picked from commit 34a12bc33f5fba02b0b398e7dff35ca041a87dc7) --- .../src/features/sharing/incoming-share-model.ts | 8 ++++++++ apps/mobile/src/lib/composerImages.ts | 5 +++++ apps/web/src/components/chat/ChatComposer.tsx | 5 +++++ packages/contracts/src/orchestration.test.ts | 7 +++++++ packages/contracts/src/orchestration.ts | 14 ++++++++++++++ 5 files changed, 39 insertions(+) diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts index 873574209..d9985a700 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -1,4 +1,5 @@ import { + isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; @@ -162,6 +163,13 @@ export async function buildIncomingShareDraft(input: { await releaseOwnedFiles(input.fileReader, [uri, payload.value]); continue; } + if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { + warnings.push( + `'${resolved?.originalName ?? fallbackName(uri, index, mimeType)}' is not a supported image type.`, + ); + await releaseOwnedFiles(input.fileReader, [uri, payload.value]); + continue; + } if ( resolved?.contentSize !== null && resolved?.contentSize !== undefined && diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index e92bb0c6e..747b7afd3 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -1,4 +1,5 @@ import { + isProviderSendTurnSupportedImageMimeType, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, type UploadChatImageAttachment, @@ -98,6 +99,10 @@ export async function pickComposerImages(input: { readonly existingCount: number error = `Unsupported file type for '${asset.fileName ?? "image"}'.`; continue; } + if (!isProviderSendTurnSupportedImageMimeType(mimeType)) { + error = `'${asset.fileName ?? "image"}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + continue; + } const base64 = asset.base64; if (!base64) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e818b0b6f..05f7a105e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -18,6 +18,7 @@ import type { import { getServerProviderSupportedRuntimeModes, resolveServerProviderRuntimeMode, + isProviderSendTurnSupportedImageMimeType, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -3055,6 +3056,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) error = `Unsupported file type for '${file.name}'. Please attach image files only.`; continue; } + if (!isProviderSendTurnSupportedImageMimeType(file.type)) { + error = `'${file.name}' is not a supported image type. Attach GIF, JPEG, PNG, or WebP images.`; + continue; + } if (reservedCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; break; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index eba1b4648..f403e6de2 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -23,6 +23,7 @@ import { ThreadCreatedPayload, ThreadTurnDiff, ThreadTurnStartRequestedPayload, + isProviderSendTurnSupportedImageMimeType, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; @@ -935,3 +936,9 @@ it.effect("project favicon overrides accept only supported image files", () => assert.strictEqual(invalid._tag, "Failure"); }), ); + +it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); + assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); +}); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d6888601c..aa62149ce 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -158,6 +158,20 @@ export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; +export const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES = [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp", +] as const; +const PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET = new Set( + PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES, +); + +/** Whether a pasted or picked image mime type can be sent on a provider turn. */ +export function isProviderSendTurnSupportedImageMimeType(mimeType: string): boolean { + return PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPE_SET.has(mimeType.toLowerCase()); +} const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; // Correlation id is command id by design in this model. From 43c1feed9d928b6c1b7535cd6cda90a4105adc77 Mon Sep 17 00:00:00 2001 From: Mihnea Peteu Date: Sat, 15 Aug 2026 15:01:29 +0300 Subject: [PATCH 36/99] Make ClaudeTextGeneration tests hermetic on Windows (#4508) Co-authored-by: Julius Marminge Co-authored-by: codex (cherry picked from commit 5ffbf3ce4a4e8d14f8f37fae304167ad6c1e5218) --- .../ClaudeTextGeneration.test.ts | 105 ++++++++++++------ 1 file changed, 70 insertions(+), 35 deletions(-) diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index e4552eab3..d1bd68cb1 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -1,12 +1,13 @@ -import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; +import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; +import { createModelSelection } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; -import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; @@ -23,47 +24,80 @@ function makeFakeClaudeBinary(dir: string) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const isWindows = yield* isHostWindows; const binDir = path.join(dir, "bin"); - const claudePath = path.join(binDir, "claude"); + const stubPath = path.join(binDir, "claude-stub.mjs"); yield* fs.makeDirectory(binDir, { recursive: true }); + // The stub behaviour lives in Node rather than a `#!/bin/sh` script so the + // same implementation is usable on Windows, where a shebang file is not + // executable and would fall through to the real Claude CLI on PATH. yield* fs.writeFileString( - claudePath, + stubPath, [ - "#!/bin/sh", - 'args="$*"', - 'stdin_content="$(cat)"', - 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', - ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "args missing expected content" >&2', - " exit 2", - " }", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', - ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', - ' printf "%s\\n" "args contained forbidden content" >&2', - " exit 3", - " fi", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', - ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "stdin missing expected content" >&2', - " exit 4", + 'const args = process.argv.slice(2).join(" ");', + "", + "function fail(message, code) {", + ' process.stderr.write(message + "\\n");', + " process.exit(code);", + "}", + "", + 'let stdinContent = "";', + "if (!process.stdin.isTTY) {", + " const chunks = [];", + " for await (const chunk of process.stdin) {", + " chunks.push(chunk);", " }", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ] && [ "$CLAUDE_CONFIG_DIR" != "$T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE" ]; then', - ' printf "%s\\n" "CLAUDE_CONFIG_DIR was $CLAUDE_CONFIG_DIR" >&2', - " exit 5", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', - ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', - "fi", - 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', - 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', + ' stdinContent = Buffer.concat(chunks).toString("utf8");', + "}", + "", + "const argsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;", + "if (argsMustContain && !args.includes(argsMustContain)) {", + ' fail("args missing expected content", 2);', + "}", + "", + "const argsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;", + "if (argsMustNotContain && args.includes(argsMustNotContain)) {", + ' fail("args contained forbidden content", 3);', + "}", + "", + "const stdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;", + "if (stdinMustContain && !stdinContent.includes(stdinMustContain)) {", + ' fail("stdin missing expected content", 4);', + "}", + "", + "const configDirMustBe = process.env.T3_FAKE_CLAUDE_CONFIG_DIR_MUST_BE;", + "if (configDirMustBe && process.env.CLAUDE_CONFIG_DIR !== configDirMustBe) {", + ' fail("CLAUDE_CONFIG_DIR was " + (process.env.CLAUDE_CONFIG_DIR ?? ""), 5);', + "}", + "", + "const stderrText = process.env.T3_FAKE_CLAUDE_STDERR;", + "if (stderrText) {", + ' process.stderr.write(stderrText + "\\n");', + "}", + "", + 'process.stdout.write(process.env.T3_FAKE_CLAUDE_OUTPUT ?? "");', + "process.exitCode = Number(process.env.T3_FAKE_CLAUDE_EXIT_CODE ?? 0);", "", ].join("\n"), ); - yield* fs.chmod(claudePath, 0o755); + + if (isWindows) { + // Windows resolves executables through PATHEXT, so the entry point has to + // carry a real extension. `resolveSpawnCommand` spawns `.cmd` via a shell. + yield* fs.writeFileString( + path.join(binDir, "claude.cmd"), + ["@echo off", 'node "%~dp0claude-stub.mjs" %*', "exit /b %ERRORLEVEL%", ""].join("\r\n"), + ); + } else { + const claudePath = path.join(binDir, "claude"); + yield* fs.writeFileString( + claudePath, + ["#!/bin/sh", 'exec node "$(dirname "$0")/claude-stub.mjs" "$@"', ""].join("\n"), + ); + yield* fs.chmod(claudePath, 0o755); + } + return binDir; }); } @@ -85,6 +119,7 @@ function withFakeClaudeEnv( const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); const binDir = yield* makeFakeClaudeBinary(tempDir); + const pathDelimiter = (yield* isHostWindows) ? ";" : ":"; const previousPath = process.env.PATH; const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; @@ -96,7 +131,7 @@ function withFakeClaudeEnv( yield* Effect.acquireRelease( Effect.sync(() => { - process.env.PATH = `${binDir}:${previousPath ?? ""}`; + process.env.PATH = `${binDir}${pathDelimiter}${previousPath ?? ""}`; process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; if (input.exitCode !== undefined) { From 09d9d14956223dd75767ca7e98fa2836ec82a26e Mon Sep 17 00:00:00 2001 From: LikoKiko Tech <145937091+LikoKiko@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:01:45 +0300 Subject: [PATCH 37/99] fix(web): show command output in work log (#4083) Co-authored-by: Julius Marminge Co-authored-by: codex (cherry picked from commit 143f713c79103b8039a7e83cc16124aadcffa424) --- .../ActivityPayloadProjection.test.ts | 53 +++++++++++ .../ActivityPayloadProjection.ts | 58 +++++++++++- .../src/session-logic.command-output.test.ts | 85 +++++++++++++++++ apps/web/src/session-logic.ts | 93 ++++++++++++++++++- 4 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/session-logic.command-output.test.ts diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index fc9ea4b62..229c7f8ab 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -44,6 +44,59 @@ describe("projectActivityPayload agent-field survival", () => { expect(data.somethingClientNeverReads).toBeUndefined(); }); + it("keeps a bounded Codex command output summary", () => { + const projected = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { + item: { + command: "/bin/zsh -lc 'printf hello'", + aggregatedOutput: `hello from codex\n${"x".repeat(5000)}`, + }, + }, + }), + ); + const data = (projected.payload as Record).data as Record; + expect(data.item).toEqual({ + command: "/bin/zsh -lc 'printf hello'", + aggregatedOutput: "hello from codex", + }); + expect(JSON.stringify(projected.payload).length).toBeLessThan(500); + }); + + it("keeps bounded Claude and ACP command output summaries", () => { + const claude = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { + command: "printf hello", + rawOutput: { stdout: `hello from claude\n${"y".repeat(5000)}` }, + }, + }), + ); + const acp = projectActivityPayload( + activity({ + itemType: "command_execution", + data: { + command: "printf hello", + content: [ + { + type: "content", + content: { type: "text", text: `hello from acp\n${"z".repeat(5000)}` }, + }, + ], + }, + }), + ); + + const claudeData = (claude.payload as Record).data as Record; + const acpData = (acp.payload as Record).data as Record; + expect(claudeData.rawOutput).toEqual({ content: "hello from claude" }); + expect(acpData.rawOutput).toEqual({ content: "hello from acp" }); + expect(JSON.stringify(claude.payload).length).toBeLessThan(500); + expect(JSON.stringify(acp.payload).length).toBeLessThan(500); + }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index f68a3ee96..e6333a1ca 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -91,14 +91,35 @@ function projectCommandData(data: Record): Record = {}; + if ("command" in result) { + projectedResult.command = result.command; + } + const content = asTrimmedString(result.content); + if (content) { + const summary = summarizeToolTextOutput(content); + if (summary) { + projectedResult.content = summary; + } + } + if (Object.keys(projectedResult).length > 0) { + projectedItem.result = projectedResult; + } } return Object.keys(projectedItem).length > 0 ? projectedItem : undefined; @@ -232,6 +253,12 @@ function projectMcpToolCallData(data: Record): Record | undefined { + const direct = asTrimmedString(value); + if (direct) { + const summary = summarizeToolTextOutput(direct); + return summary ? { content: summary } : undefined; + } + const rawOutput = asRecord(value); if (!rawOutput) { return undefined; @@ -256,9 +283,34 @@ function projectRawOutput(value: unknown): Record | undefined { return summary ? { content: summary } : undefined; } + const stderr = asTrimmedString(rawOutput.stderr); + if (stderr) { + const summary = summarizeToolTextOutput(stderr); + return summary ? { content: summary } : undefined; + } + return undefined; } +function projectAcpContent(value: unknown): Record | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const text = value + .map((entryValue) => { + const entry = asRecord(entryValue); + const content = asRecord(entry?.content); + return entry?.type === "content" && content?.type === "text" + ? asTrimmedString(content.text) + : null; + }) + .filter((entry): entry is string => entry !== null) + .join("\n"); + const summary = summarizeToolTextOutput(text); + return summary ? { content: summary } : undefined; +} + /** * Removes activity payload fields that no current client reads while retaining * the full payload in persistence and the event store. @@ -305,7 +357,7 @@ export function projectActivityPayload( projectedData.kind = data.kind; } - const rawOutput = projectRawOutput(data.rawOutput); + const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content); if (rawOutput) { projectedData.rawOutput = rawOutput; } diff --git a/apps/web/src/session-logic.command-output.test.ts b/apps/web/src/session-logic.command-output.test.ts new file mode 100644 index 000000000..570629046 --- /dev/null +++ b/apps/web/src/session-logic.command-output.test.ts @@ -0,0 +1,85 @@ +import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { deriveWorkLogEntries } from "./session-logic"; + +function makeCommandActivity( + id: string, + payload: Record, +): OrchestrationThreadActivity { + return { + id: EventId.make(id), + createdAt: "2026-07-17T10:00:00.000Z", + kind: "tool.completed", + summary: "Ran command", + tone: "tool", + payload, + turnId: TurnId.make("turn-1"), + }; +} + +describe("deriveWorkLogEntries command output", () => { + it("uses Codex aggregated output instead of repeating the command", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("codex-command", { + itemType: "command_execution", + title: "Ran command", + detail: "/bin/zsh -lc \"printf 'hello\\n'\"", + data: { + item: { + type: "commandExecution", + command: "/bin/zsh -lc \"printf 'hello\\n'\"", + commandActions: [{ command: "printf 'hello\\n'", type: "unknown" }], + aggregatedOutput: "hello\n", + status: "completed", + }, + }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf 'hello\\n'", + rawCommand: "/bin/zsh -lc \"printf 'hello\\n'\"", + detail: "hello", + }); + }); + + it("uses a projected Claude output summary instead of repeating the command", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("claude-command", { + itemType: "command_execution", + title: "Ran command", + detail: "printf hello", + data: { + kind: "execute", + command: "printf hello", + rawOutput: { + content: "hello from claude", + }, + }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf hello", + detail: "hello from claude", + }); + }); + + it("drops duplicated command detail when the command has no output", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("empty-command", { + itemType: "command_execution", + title: "Ran command", + detail: "true", + data: { + kind: "execute", + command: "true", + }, + }), + ]); + + expect(entry?.command).toBe("true"); + expect(entry?.detail).toBeUndefined(); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 14fcc7715..a0fa6d8dc 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1413,6 +1413,70 @@ function summarizeToolRawOutput(payload: Record | null): string return null; } +function extractAcpTextContent(value: unknown): string | null { + if (!Array.isArray(value)) { + return null; + } + + const chunks: string[] = []; + for (const entryValue of value) { + const entry = asRecord(entryValue); + if (entry?.type !== "content") { + continue; + } + const content = asRecord(entry.content); + if (content?.type !== "text") { + continue; + } + const text = asTrimmedString(content.text); + if (text) { + chunks.push(text); + } + } + + return chunks.length > 0 ? chunks.join("\n") : null; +} + +function extractToolOutput(payload: Record | null): string | null { + const data = asRecord(payload?.data); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const rawOutput = asRecord(data?.rawOutput); + + const outputStreams: string[] = []; + const stdout = asTrimmedString(rawOutput?.stdout); + const stderr = asTrimmedString(rawOutput?.stderr); + if (stdout) { + outputStreams.push(stdout); + } + if (stderr) { + outputStreams.push(stderr); + } + + const candidates: unknown[] = [ + item?.aggregatedOutput, + itemResult?.content, + data?.rawOutput, + rawOutput?.content, + outputStreams.length > 0 ? outputStreams.join("\n") : null, + rawOutput?.output, + extractAcpTextContent(data?.content), + ]; + + for (const candidate of candidates) { + const text = asTrimmedString(candidate); + if (!text) { + continue; + } + const output = stripTrailingExitCode(text).output; + if (output) { + return output; + } + } + + return null; +} + function isCommandToolDetail(payload: Record | null, heading: string): boolean { const data = asRecord(payload?.data); const kind = asTrimmedString(data?.kind)?.toLowerCase(); @@ -1433,12 +1497,37 @@ function extractToolDetail( const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null; const normalizedHeading = normalizePreviewForComparison(heading); const normalizedDetail = normalizePreviewForComparison(detail); + const commandTool = isCommandToolDetail(payload, heading); + const commandPreview = commandTool + ? extractToolCommand(payload) + : { command: null, rawCommand: null }; + const command = commandPreview.command; + const normalizedCommand = normalizePreviewForComparison(command); + const normalizedRawCommand = normalizePreviewForComparison(commandPreview.rawCommand); - if (detail && normalizedHeading !== normalizedDetail) { + if ( + detail && + normalizedHeading !== normalizedDetail && + (!commandTool || + (normalizedCommand !== normalizedDetail && normalizedRawCommand !== normalizedDetail)) + ) { return detail; } - if (isCommandToolDetail(payload, heading)) { + if (commandTool) { + if (!command) { + return null; + } + + const output = extractToolOutput(payload); + const normalizedOutput = normalizePreviewForComparison(output); + if ( + output && + normalizedOutput !== normalizedHeading && + normalizedOutput !== normalizedCommand + ) { + return output; + } return null; } From 9dabd9b0733f63ab7fe8b17a93229da9065a2d73 Mon Sep 17 00:00:00 2001 From: Vividh Mahajan <82711162+Lasdw6@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:01:47 -0400 Subject: [PATCH 38/99] fix(web): reserve sibling column width when resizing the right panel (#6279) (cherry picked from commit 06dd9993b6292e61de8890956a3ce3c50495a6eb) --- .../preview/PreviewPanelShell.test.ts | 26 +++++++ .../components/preview/PreviewPanelShell.tsx | 74 ++++++++++++++++--- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts index 31258b166..23deb066a 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.test.ts +++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts @@ -20,4 +20,30 @@ describe("getPreviewPanelMaxWidth", () => { expect(markup).toContain("max-w-full"); }); + + it("reserves the sibling column minimum when the flex row is known", () => { + // Fullscreen 14" MacBook: viewport 1512, sidebar ~256 → row of 1256. + // The 70% fraction (1058) would leave the chat column only ~198px; + // the container clamp caps the panel at 1256 − 360 instead. + expect(getPreviewPanelMaxWidth(1_512, 1_256)).toBe(896); + }); + + it("keeps the fraction cap when the row is wide enough for both columns", () => { + expect(getPreviewPanelMaxWidth(3_000, 2_900)).toBe(2_100); + }); + + it("rounds fractional row widths down", () => { + expect(getPreviewPanelMaxWidth(1_512, 1_256.6)).toBe(896); + }); + + it("never drops below the panel minimum when the row cannot fit both columns", () => { + // ~1000px window with an expanded sidebar → row of 700. The sibling + // reservation (700 − 360 = 340) would undercut the panel's own 360 + // minimum and invert the resize clamp, so the floor wins. + expect(getPreviewPanelMaxWidth(1_000, 700)).toBe(360); + }); + + it("stays at the panel minimum even when the row is narrower than the reservation", () => { + expect(getPreviewPanelMaxWidth(1_512, 300)).toBe(360); + }); }); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 17ca389fe..7a20c2eaa 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -1,4 +1,11 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { + type ReactNode, + type RefObject, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { isElectron } from "~/env"; import { useResizableWidth } from "~/hooks/useResizableWidth"; @@ -10,12 +17,31 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; const PREVIEW_PANEL_MIN_WIDTH = 360; -/** Fraction of the viewport allowed, preserving the remaining space for chat. */ +/** + * Upper bound as a fraction of the viewport; only binds on wide screens. + * On narrow windows the container clamp below is what preserves the + * sibling column's space. + */ const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; const PREVIEW_PANEL_DEFAULT_WIDTH = 540; +/** + * Width reserved for the sibling column (chat, pull-request list) sharing the + * panel's flex row. The viewport fraction alone is not enough: the app + * sidebar sits outside the row, so on narrow windows (any MacBook, even + * fullscreen) the remaining 30% of the viewport minus the sidebar left the + * sibling below its usable width and the composer overflowed. + */ +const SIBLING_COLUMN_MIN_WIDTH = 360; -export function getPreviewPanelMaxWidth(viewportWidth: number): number { - return Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); +export function getPreviewPanelMaxWidth(viewportWidth: number, containerWidth?: number): number { + const fractionCap = Math.floor(viewportWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); + const containerCap = + containerWidth === undefined ? Infinity : Math.floor(containerWidth) - SIBLING_COLUMN_MIN_WIDTH; + // Never below the panel's own minimum: when the row cannot fit both + // columns' minimums the sibling yields, and useResizableWidth's clamp + // must not see max < min (it would resolve the inversion to min and, + // via drag-end persistence, overwrite the user's stored width). + return Math.max(PREVIEW_PANEL_MIN_WIDTH, Math.min(fractionCap, containerCap)); } /** @@ -39,7 +65,10 @@ export function PreviewPanelShell(props: { }) { const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; const isInline = props.mode === "inline"; - const maxWidth = useViewportClampedMaxWidth(); + const hostRef = useRef(null); + // Only inline non-maximized mode applies `width`/`maxWidth`; skip the + // container measurement (and its re-renders) everywhere else. + const maxWidth = useClampedMaxWidth(hostRef, isInline && !props.maximized); const { width, handlers } = useResizableWidth({ storageKey: props.widthStorageKey ?? PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: props.defaultWidth ?? PREVIEW_PANEL_DEFAULT_WIDTH, @@ -50,6 +79,7 @@ export function PreviewPanelShell(props: { return (
    , enabled: boolean): number { const [vw, setVw] = useState(() => (typeof window === "undefined" ? 1280 : window.innerWidth)); + const [containerWidth, setContainerWidth] = useState(undefined); useEffect(() => { if (typeof window === "undefined") return; let frame = 0; @@ -93,5 +128,24 @@ function useViewportClampedMaxWidth(): number { if (frame !== 0) window.cancelAnimationFrame(frame); }; }, []); - return getPreviewPanelMaxWidth(vw); + useLayoutEffect(() => { + if (!enabled) return; + const parent = hostRef.current?.parentElement; + if (!parent) return; + // Measure before first paint: the persisted width must be clamped + // against the row on the initial render, not one observer tick later + // (the panel would flash over-wide on every mount). clientWidth is + // integral, so sub-pixel resize deltas bail out of re-rendering. + const measure = () => { + setContainerWidth(parent.clientWidth); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(parent); + return () => { + observer.disconnect(); + }; + }, [hostRef, enabled]); + return getPreviewPanelMaxWidth(vw, containerWidth); } From b38989cd69f9358de176d0363423285060bf0699 Mon Sep 17 00:00:00 2001 From: Jorge Pineda Date: Sat, 15 Aug 2026 07:02:03 -0500 Subject: [PATCH 39/99] fix(web): replace whitespace in new ref names with dashes (#6270) (cherry picked from commit 9e61d0f127cad164702ca37aad6f5f38babc1ac7) --- .../components/BranchToolbar.logic.test.ts | 90 +++++++++++++++++++ .../web/src/components/BranchToolbar.logic.ts | 28 +++++- .../BranchToolbarBranchSelector.tsx | 20 +++-- 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 36d42a60f..251b07688 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -16,6 +16,7 @@ import { resolveLocalCheckoutBranchMismatch, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, + sanitizeNewRefName, shouldIncludeBranchPickerItem, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, @@ -729,4 +730,93 @@ describe("shouldIncludeBranchPickerItem", () => { }), ).toBe(false); }); + + // Typing a spaced name must still surface the ref it would have been created + // as, or the picker shows nothing at all for that query. + it("surfaces an existing ref matching the sanitized query", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "new-branch", + normalizedQuery: "new branch", + createBranchItemValue: null, + checkoutPullRequestItemValue: null, + }), + ).toBe(true); + }); + + // A partial query has to reach the ref it would have been created as, so + // searching "hello w" still finds an existing hello-world. + it("surfaces a ref from a partial query containing a space", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "hello-world", + normalizedQuery: "hello w", + createBranchItemValue: null, + checkoutPullRequestItemValue: null, + }), + ).toBe(true); + }); + + it("excludes refs matching neither the raw nor the sanitized query", () => { + expect( + shouldIncludeBranchPickerItem({ + itemValue: "main", + normalizedQuery: "new branch", + createBranchItemValue: null, + checkoutPullRequestItemValue: null, + }), + ).toBe(false); + }); +}); + +// Git rejects ASCII space and the ASCII control characters in ref names, so a +// typed name like "new branch" can only ever fail. Replacing exactly those can +// turn a failing name into a working one without touching a name git already +// accepts, including one holding non-ASCII whitespace such as U+00A0. +describe("sanitizeNewRefName", () => { + it("replaces a space with a dash", () => { + expect(sanitizeNewRefName("new branch")).toBe("new-branch"); + }); + + it("collapses a run of whitespace into a single dash", () => { + expect(sanitizeNewRefName("new branch")).toBe("new-branch"); + }); + + it("trims surrounding whitespace instead of turning it into dashes", () => { + expect(sanitizeNewRefName(" new branch ")).toBe("new-branch"); + }); + + it("replaces tabs, which git rejects just like spaces", () => { + expect(sanitizeNewRefName("new\tbranch")).toBe("new-branch"); + }); + + // git accepts U+00A0, U+2009 and other non-ASCII whitespace in ref names, so + // rewriting them would silently create a ref the user never typed. + it("preserves whitespace that git accepts", () => { + expect(sanitizeNewRefName("new\u00a0branch")).toBe("new\u00a0branch"); + expect(sanitizeNewRefName("new\u2009branch")).toBe("new\u2009branch"); + }); + + it("keeps slashes so nested ref names survive", () => { + expect(sanitizeNewRefName("feature/new thing")).toBe("feature/new-thing"); + }); + + it("preserves case because git ref names are case sensitive", () => { + expect(sanitizeNewRefName("Feature/New Thing")).toBe("Feature/New-Thing"); + }); + + it("leaves an already valid ref name untouched", () => { + expect(sanitizeNewRefName("feature/login")).toBe("feature/login"); + }); + + it("returns an empty string for whitespace-only input", () => { + expect(sanitizeNewRefName(" ")).toBe(""); + }); + + // Scoped deliberately to whitespace: git accepts consecutive dashes, so + // collapsing them would rewrite names the user may have typed on purpose. + it("does not collapse dashes the user typed", () => { + expect(sanitizeNewRefName("new - branch")).toBe("new---branch"); + expect(sanitizeNewRefName("foo--bar")).toBe("foo--bar"); + }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 485ffbf8d..0a8e07d19 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -243,6 +243,19 @@ export function resolveBranchSelectionTarget(input: { }; } +// Git rejects ASCII space and the ASCII control characters (tab, newline and +// friends) in ref names, so the picker's "Create new ref" entry can only fail +// for a typed name like "new branch". Replacing runs of those with a dash makes +// the name usable without reimplementing check-ref-format: names invalid for +// other reasons still surface the git error. Only the whitespace git actually +// rejects is replaced — git accepts U+00A0 and friends, and rewriting those +// would silently create a ref the user never asked for. Case and existing +// dashes are left alone, since ref names are case sensitive and consecutive +// dashes are valid. +export function sanitizeNewRefName(rawName: string): string { + return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-"); +} + export function shouldIncludeBranchPickerItem(input: { itemValue: string; normalizedQuery: string; @@ -263,5 +276,18 @@ export function shouldIncludeBranchPickerItem(input: { return true; } - return itemValue.toLowerCase().includes(normalizedQuery); + const lowerItemValue = itemValue.toLowerCase(); + if (lowerItemValue.includes(normalizedQuery)) { + return true; + } + + // A query containing whitespace can only ever match a ref under its sanitized + // name, because that is the name such a ref would have been created with. + // Without this, typing "new branch" hides an existing "new-branch". + const sanitizedQuery = sanitizeNewRefName(normalizedQuery); + return ( + sanitizedQuery.length > 0 && + sanitizedQuery !== normalizedQuery && + lowerItemValue.includes(sanitizedQuery) + ); } diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index b3c1c08eb..5fcad2f74 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -42,6 +42,7 @@ import { resolveBranchToolbarValue, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, + sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; import { @@ -220,13 +221,18 @@ export function BranchToolbarBranchSelector({ ); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); + // The server filters refs by substring, so it has to be given the sanitized + // name as well: querying the raw "new branch" drops an existing new-branch + // from the response entirely, which would defeat the collision check below. + // Ref names cannot contain an ASCII space, so sanitizing loses no matches. + const branchRefQuery = sanitizeNewRefName(deferredTrimmedBranchQuery); const branchRefTarget = useMemo( () => ({ environmentId, cwd: branchCwd, - query: deferredTrimmedBranchQuery, + query: branchRefQuery, }), - [branchCwd, deferredTrimmedBranchQuery, environmentId], + [branchCwd, branchRefQuery, environmentId], ); const branchRefState = usePaginatedBranches(branchRefTarget); const refs = branchRefState.refs; @@ -259,7 +265,11 @@ export function BranchToolbarBranchSelector({ const checkoutPullRequestItemValue = prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` : null; const canCreateBranch = !isSelectingWorktreeBase && trimmedBranchQuery.length > 0; - const hasExactBranchMatch = branchByName.has(trimmedBranchQuery); + // The ref is created under its sanitized name, so the collision check has to + // use that name too. Matching on the raw query would offer to create a ref + // that already exists whenever sanitizing changes the name. + const newRefName = sanitizeNewRefName(trimmedBranchQuery); + const hasExactBranchMatch = branchByName.has(newRefName); const createBranchItemValue = canCreateBranch ? `__create_new_branch__:${trimmedBranchQuery}` : null; @@ -441,7 +451,7 @@ export function BranchToolbarBranchSelector({ }; const createRef = (rawName: string) => { - const name = rawName.trim(); + const name = sanitizeNewRefName(rawName); if (!branchCwd || !name || isBranchActionPending) return; setIsBranchMenuOpen(false); @@ -659,7 +669,7 @@ export function BranchToolbarBranchSelector({ className="pe-1.5" onClick={() => createRef(trimmedBranchQuery)} > - Create new ref "{trimmedBranchQuery}" + Create new ref "{newRefName}" ); } From e2591994393e08b49a8f831229b7f6c99ce7fa92 Mon Sep 17 00:00:00 2001 From: abhwshek <67309069+a20hek@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:32:21 +0530 Subject: [PATCH 40/99] fix(client-runtime): branch list no longer resets while paging through refs (#5858) (cherry picked from commit c7b14a8666dc44ee426f8d139e0493af9fe4b6db) --- packages/client-runtime/src/state/vcs.ts | 41 +++++++++++++----------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index a0d4510be..042548336 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -236,29 +236,32 @@ export function cachedVcsRefsChanges( export function createVcsEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { - const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) => - Atom.family((inputKey: string) => { - const input = JSON.parse(inputKey) as VcsListRefsInput; - return runtime - .atom((get) => { - const state = get(vcsRefsCacheStateAtom({ environmentId })); - return cachedVcsRefsChanges( - environmentId, - input, - state.revision, - state.persistedCacheReadable, - ); - }) - .pipe( - Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), - Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), + /** + * One flat family on purpose: families hold entries via WeakRef, so a nested + * per-environment family can be collected between lookups, dropping every + * cached page atom and collapsing paginated ref lists mid-scroll. + */ + const listRefsFamily = Atom.family((key: string) => { + const [environmentId, input] = JSON.parse(key) as [EnvironmentId, VcsListRefsInput]; + return runtime + .atom((get) => { + const state = get(vcsRefsCacheStateAtom({ environmentId })); + return cachedVcsRefsChanges( + environmentId, + input, + state.revision, + state.persistedCacheReadable, ); - }), - ); + }) + .pipe( + Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), + Atom.withLabel(`environment-data:vcs:list-refs:${key}`), + ); + }); const listRefs = (target: { readonly environmentId: EnvironmentId; readonly input: VcsListRefsInput; - }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input)); + }) => listRefsFamily(JSON.stringify([target.environmentId, target.input])); const invalidateRefs = ( target: { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, registry: AtomRegistry.AtomRegistry, From c0675be19df92e699a46f09f71da299e3b91f011 Mon Sep 17 00:00:00 2001 From: aoright <102943475+aoright@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:02:24 +0800 Subject: [PATCH 41/99] fix(web): support Shift+Insert terminal paste (#5982) (cherry picked from commit b0de3857775b7d1407372cde031326e15a9d4119) --- apps/web/src/terminal/ghostty/surface.test.ts | 16 ++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 18cf95901..c11529e0c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -253,6 +253,22 @@ describe("isTerminalPasteShortcut", () => { true, ); }); + + it("supports the conventional Shift+Insert paste shortcut", () => { + expect(isTerminalPasteShortcut(event({ key: "Insert", shiftKey: true }), "Linux x86_64")).toBe( + true, + ); + expect(isTerminalPasteShortcut(event({ key: "Insert" }), "Linux x86_64")).toBe(false); + expect( + isTerminalPasteShortcut( + event({ key: "Insert", ctrlKey: true, shiftKey: true }), + "Linux x86_64", + ), + ).toBe(false); + expect(isTerminalPasteShortcut(event({ key: "Insert", shiftKey: true }), "MacIntel")).toBe( + false, + ); + }); }); describe("isTerminalCompositionCommitInput", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index c4590e8db..e95528904 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -340,7 +340,11 @@ export function isTerminalPasteShortcut( event: Pick, platform = navigator.platform, ) { - if (event.key.toLowerCase() !== "v") return false; + const key = event.key.toLowerCase(); + if (key === "insert" && !isMacPlatform(platform)) { + return event.shiftKey && !event.ctrlKey && !event.metaKey; + } + if (key !== "v") return false; return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } From 5826748d4d10c68f529320f4032c8a146119ee10 Mon Sep 17 00:00:00 2001 From: Williawar <28518115+Williawar@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:02:41 +1000 Subject: [PATCH 42/99] fix(web): keep the composer glass aligned with the context strip at any interface font size (#5703) Co-authored-by: Claude Fable 5 (cherry picked from commit d79f975d0b39805c443e97accfcd4aa60786f13f) --- apps/web/src/index.css | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d93615e11..ed3d5732a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -769,27 +769,31 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil .chat-composer-glass-shell-with-context::before { border-radius: 0; /* - * One continuous glass layer: a 22px composer joined to a 16px strip, - * whose visible sides align with the composer's bottom tangents. + * One continuous glass layer: a 22px composer joined to a 16px strip. The + * strip is inset 1.375rem per side, so the step-in positions and their + * curve controls stay in rem to keep tracking it at non-default interface + * font sizes; the composer's 22px top radius and the strip's 16px bottom + * radius are px by design. */ clip-path: shape( from 0 22px, curve to 22px 0 with 0 9.85px / 9.85px 0, line to calc(100% - 22px) 0, curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px, - line to 100% calc(100% - var(--chat-composer-context-extension) - 22px), - curve to calc(100% - 22px) calc(100% - var(--chat-composer-context-extension)) with 100% - calc(100% - var(--chat-composer-context-extension) - 9.85px) / calc(100% - 9.85px) + line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem), + curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100% + calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem) calc(100% - var(--chat-composer-context-extension)), - line to calc(100% - 22px) calc(100% - 16px), - curve to calc(100% - 38px) 100% with calc(100% - 22px) calc(100% - 7.16px) / - calc(100% - 29.16px) 100%, - line to 38px 100%, - curve to 22px calc(100% - 16px) with 29.16px 100% / 22px calc(100% - 7.16px), - line to 22px calc(100% - var(--chat-composer-context-extension)), - curve to 0 calc(100% - var(--chat-composer-context-extension) - 22px) with 9.85px + line to calc(100% - 1.375rem) calc(100% - 16px), + curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) / + calc(100% - 1.375rem - 7.16px) 100%, + line to calc(1.375rem + 16px) 100%, + curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem + calc(100% - 7.16px), + line to 1.375rem calc(100% - var(--chat-composer-context-extension)), + curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem calc(100% - var(--chat-composer-context-extension)) / 0 - calc(100% - var(--chat-composer-context-extension) - 9.85px), + calc(100% - var(--chat-composer-context-extension) - 0.6156rem), line to 0 22px, close ); From 7297a1ab5678e906c221eac47f7ceda3201f7908 Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:02:44 -0400 Subject: [PATCH 43/99] fix(codex): keep background memory out of chats (#5468) (cherry picked from commit 135dc156e3f600a79a415b06e4c234677aa318d0) --- .../CodexCollabRuntime.integration.test.ts | 36 ++++- .../Layers/CodexSessionRuntime.test.ts | 140 ++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 51 +++++++ 3 files changed, 226 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index d6802cf82..635f89967 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -29,6 +29,7 @@ const CHILD_A_TURN_ID = ( (entry) => entry.method === "turn/started" && entry.params.threadId === CHILD_A, )?.params as { readonly turn?: { readonly id?: string } } )?.turn?.id; +const MEMORY = "memory-consolidation-thread"; /** * The captured sequence, extended with the shapes the live capture didn't @@ -195,15 +196,44 @@ describe("CodexSessionRuntime collab integration", () => { const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); + const rootThreadStarted = byIndex.find((entry) => entry.method === "thread/started"); assert.isDefined(turnStartedA); assert.isDefined(turnStartedB); assert.isDefined(registrationA); assert.isDefined(registrationB); + assert.isDefined(rootThreadStarted); + const memoryThreadStarted = { + ...rootThreadStarted, + params: { + thread: { + ...rootThreadStarted.params.thread, + id: MEMORY, + sessionId: MEMORY, + source: "unknown", + threadSource: "memory_consolidation", + }, + }, + }; + const memoryTurnStarted = { + ...turnStartedA, + params: { + ...turnStartedA.params, + threadId: MEMORY, + turn: { ...turnStartedA.params.turn, id: "memory-consolidation-turn" }, + }, + }; const script = { rootThreadId: ROOT, holdTurnOpen: true, hangInterruptFor: CHILD_A, - notifications: [turnStartedA, registrationA, registrationB, turnStartedB], + notifications: [ + turnStartedA, + registrationA, + memoryThreadStarted, + memoryTurnStarted, + registrationB, + turnStartedB, + ], }; // @effect-diagnostics-next-line preferSchemaOverJson:off NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); @@ -261,6 +291,10 @@ describe("CodexSessionRuntime collab integration", () => { "pre-registration child A must still receive the interrupt RPC", ); assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); + assert.isTrue( + interruptedThreads.has(MEMORY), + "memory consolidation must be interrupted without appearing in chat", + ); assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); yield* runtime.close; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index ee2c462ac..53bf283f6 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -7,6 +7,7 @@ import { describe } from "vite-plus/test"; import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; +import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexDeveloperInstructions, @@ -18,6 +19,7 @@ import { buildTurnStartParams, hasConfiguredMcpServer, isRecoverableThreadResumeError, + makeMemoryConsolidationNotificationFilter, openCodexThread, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -318,6 +320,144 @@ describe("hasConfiguredMcpServer", () => { }); }); +function makeThreadStartedNotification( + threadId: string, + source: EffectCodexSchema.V2ThreadStartedNotification["thread"]["source"], + threadSource?: string, +) { + return { + method: "thread/started" as const, + params: { + thread: { + cliVersion: "0.0.0", + createdAt: 0, + cwd: "/tmp/project", + ephemeral: true, + id: threadId, + modelProvider: "openai", + preview: "", + sessionId: threadId, + source, + status: { type: "idle" as const }, + ...(threadSource ? { threadSource } : {}), + turns: [], + updatedAt: 0, + }, + }, + }; +} + +describe("makeMemoryConsolidationNotificationFilter", () => { + it("suppresses memory consolidation without hiding other Codex subagents", () => { + const shouldSuppress = makeMemoryConsolidationNotificationFilter(); + + NodeAssert.equal( + shouldSuppress( + makeThreadStartedNotification("memory-thread", "unknown", "memory_consolidation"), + ), + true, + ); + NodeAssert.equal( + shouldSuppress({ + method: "item/agentMessage/delta", + params: { + delta: "internal memory update", + itemId: "memory-message", + threadId: "memory-thread", + turnId: "memory-turn", + }, + }), + true, + ); + NodeAssert.equal( + shouldSuppress({ + method: "serverRequest/resolved", + params: { + requestId: "memory-approval", + threadId: "memory-thread", + }, + }), + false, + ); + NodeAssert.equal( + shouldSuppress({ + method: "warning", + params: { + message: "internal warning", + threadId: "memory-thread", + }, + }), + true, + ); + NodeAssert.equal( + shouldSuppress({ + method: "item/agentMessage/delta", + params: { + delta: "normal reply", + itemId: "root-message", + threadId: "root-thread", + turnId: "root-turn", + }, + }), + false, + ); + + NodeAssert.equal( + shouldSuppress( + makeThreadStartedNotification("legacy-memory-thread", { + subAgent: "memory_consolidation", + }), + ), + true, + ); + + for (const source of [ + { subAgent: "review" as const }, + { subAgent: "compact" as const }, + { + subAgent: { + thread_spawn: { + depth: 1, + parent_thread_id: "root-thread", + }, + }, + }, + ]) { + NodeAssert.equal( + shouldSuppress(makeThreadStartedNotification("visible-subagent", source)), + false, + ); + } + }); + + it("forgets memory consolidation threads after they close", () => { + const shouldSuppress = makeMemoryConsolidationNotificationFilter(); + shouldSuppress( + makeThreadStartedNotification("memory-thread", "unknown", "memory_consolidation"), + ); + + NodeAssert.equal( + shouldSuppress({ + method: "thread/closed", + params: { threadId: "memory-thread" }, + }), + true, + ); + NodeAssert.equal( + shouldSuppress({ + method: "item/agentMessage/delta", + params: { + delta: "later message", + itemId: "later-message", + threadId: "memory-thread", + turnId: "later-turn", + }, + }), + false, + ); + }); +}); + describe("codexSessionAppServerArgs", () => { it("keeps the app-server subcommand when explicit args are provided", () => { NodeAssert.deepStrictEqual(codexSessionAppServerArgs(["-c", "model=gpt-5"], undefined), [ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index fd870100d..86023a59c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -541,6 +541,49 @@ function readNotificationThreadId(notification: CodexServerNotification): string } } +export function makeMemoryConsolidationNotificationFilter(): ( + notification: CodexServerNotification, +) => boolean { + const threadIds = new Set(); + + return (notification) => { + if (notification.method === "thread/started") { + const thread = notification.params.thread; + const source = thread.source; + if ( + thread.threadSource === "memory_consolidation" || + (typeof source === "object" && + source !== null && + "subAgent" in source && + source.subAgent === "memory_consolidation") + ) { + threadIds.add(thread.id); + return true; + } + } + + const params = notification.params; + const threadId = + notification.method === "thread/started" + ? notification.params.thread.id + : "threadId" in params && typeof params.threadId === "string" + ? params.threadId + : undefined; + if (!threadId || !threadIds.has(threadId)) { + return false; + } + + if (notification.method === "serverRequest/resolved") { + return false; + } + + if (notification.method === "thread/closed") { + threadIds.delete(threadId); + } + return true; + }; +} + function readRouteFields(notification: CodexServerNotification): { readonly turnId: TurnId | undefined; readonly itemId: ProviderItemId | undefined; @@ -857,6 +900,7 @@ export const makeCodexSessionRuntime = ( const collabChildAgentsRef = 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(); const closedRef = yield* Ref.make(false); // `~` is not shell-expanded when env vars are set via @@ -1256,6 +1300,9 @@ export const makeCodexSessionRuntime = ( const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { + const isMemoryConsolidationNotification = + suppressMemoryConsolidationNotification(notification); + const payload = notification.params; const route = readRouteFields(notification); const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); @@ -1333,6 +1380,10 @@ export const makeCodexSessionRuntime = ( return; } + if (isMemoryConsolidationNotification) { + return; + } + let requestId: ApprovalRequestId | undefined; let requestKind: ProviderRequestKind | undefined; let turnId = childParentTurnId ?? route.turnId; From 8b849a53ab1a5fc346f4d560619e28379767a55c Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:32:55 +0530 Subject: [PATCH 44/99] fix(server): treat a missing Codex rollout as a recoverable resume error (#6671) (cherry picked from commit 51c6daa3bd1d65e809b5196bf63333013427338a) --- .../src/provider/Layers/CodexSessionRuntime.test.ts | 12 ++++++++++++ .../src/provider/Layers/CodexSessionRuntime.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 53bf283f6..c5b9c6d36 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -498,6 +498,18 @@ describe("isRecoverableThreadResumeError", () => { ); }); + it("matches a missing rollout for a known thread id", () => { + NodeAssert.equal( + isRecoverableThreadResumeError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "no rollout found for thread id 019fdf74-aaa9-7950-b252-7cc7a8650470", + }), + ), + true, + ); + }); + it("ignores non-recoverable resume errors", () => { NodeAssert.equal( isRecoverableThreadResumeError( diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 86023a59c..908cdb143 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -58,6 +58,7 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "no such thread", "unknown thread", "does not exist", + "no rollout found", ]; export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | undefined): boolean { From 855218d897801144a07a30a20e36a0a0498c1517 Mon Sep 17 00:00:00 2001 From: Mark Griffin Date: Sat, 15 Aug 2026 13:03:14 +0100 Subject: [PATCH 45/99] fix(web): hide provider Update toast action while an update is running (#6544) Co-authored-by: Cursor (cherry picked from commit 3cde99b259249a02e50079f326a00b5ba38e7235) --- .../ProviderUpdatePrimaryNotification.tsx | 10 ++++----- .../web/src/components/ui/toast.logic.test.ts | 13 ++++++++++++ apps/web/src/components/ui/toast.logic.ts | 16 ++++++++++++++ apps/web/src/components/ui/toast.tsx | 11 +++++----- .../src/components/ui/toastHelpers.test.ts | 21 +++++++++++++++++++ apps/web/src/components/ui/toastHelpers.ts | 8 +++++++ 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/components/ui/toastHelpers.test.ts diff --git a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx index 64399679a..00112ccec 100644 --- a/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx +++ b/apps/web/src/components/ProviderUpdatePrimaryNotification.tsx @@ -20,7 +20,7 @@ import { providerUpdateNotificationKey, type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; -import { stackedThreadToast, toastManager } from "./ui/toast"; +import { hiddenToastActionProps, stackedThreadToast, toastManager } from "./ui/toast"; import { useAtomCommand } from "../state/use-atom-command"; const seenProviderUpdateNotificationKeys = new Set(); @@ -68,10 +68,10 @@ function updateProviderUpdateToast(input: { title: input.view.title, description: input.view.description, timeout: 0, - // Base UI merges toast updates with the existing toast. Explicitly clear - // the prompt action so its guarded Update handler cannot linger as a - // visible no-op while the update is running (or after it succeeds). - actionProps: undefined, + // Base UI merges toast updates and omits `undefined` keys, so `undefined` + // would leave the prompt's Update button in place. Replace it with a + // defined empty action so the CTA cannot linger while the update runs. + actionProps: hiddenToastActionProps, data: { hideCopyButton: true, ...(input.view.dismissAfterVisibleMs !== undefined diff --git a/apps/web/src/components/ui/toast.logic.test.ts b/apps/web/src/components/ui/toast.logic.test.ts index 576b970bb..f711d2b32 100644 --- a/apps/web/src/components/ui/toast.logic.test.ts +++ b/apps/web/src/components/ui/toast.logic.test.ts @@ -2,10 +2,23 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { assert, describe, it } from "vite-plus/test"; import { buildVisibleToastLayout, + hasVisibleToastAction, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; +describe("hasVisibleToastAction", () => { + it("treats a labeled action as visible", () => { + assert.equal(hasVisibleToastAction({ children: "Update" }), true); + }); + + it("hides an explicit empty action used to clear a previous CTA", () => { + assert.equal(hasVisibleToastAction({ children: null }), false); + assert.equal(hasVisibleToastAction({ children: "" }), false); + assert.equal(hasVisibleToastAction(undefined), false); + }); +}); + describe("shouldHideCollapsedToastContent", () => { it("keeps a single visible toast readable", () => { assert.equal(shouldHideCollapsedToastContent(0, 1), false); diff --git a/apps/web/src/components/ui/toast.logic.ts b/apps/web/src/components/ui/toast.logic.ts index 80f23970c..62c1a8af7 100644 --- a/apps/web/src/components/ui/toast.logic.ts +++ b/apps/web/src/components/ui/toast.logic.ts @@ -1,5 +1,21 @@ import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +/** + * Base UI toast updates omit `undefined` fields, so callers that need to remove + * an action must pass a defined `actionProps` whose `children` are empty. + * Treat that payload (and missing children) as "no visible action". + */ +export function hasVisibleToastAction(actionProps: unknown): boolean { + if (actionProps == null || typeof actionProps !== "object") { + return false; + } + if (!("children" in actionProps)) { + return false; + } + const children = actionProps.children; + return children != null && children !== false && children !== ""; +} + export function shouldHideCollapsedToastContent( visibleToastIndex: number, visibleToastCount: number, diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 232349d45..69fd0ebf3 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -32,6 +32,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { resolveThreadRouteTarget } from "~/threadRoutes"; import { buildVisibleToastLayout, + hasVisibleToastAction, shouldHideCollapsedToastContent, shouldRenderThreadScopedToast, } from "./toast.logic"; @@ -288,7 +289,7 @@ function deriveToastBodyDescriptor(toast: { }): ToastBodyDescriptor { const Icon = toast.type ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] : null; const stackedActionLayout = - toast.actionProps !== undefined && toast.data?.actionLayout === "stacked-end"; + hasVisibleToastAction(toast.actionProps) && toast.data?.actionLayout === "stacked-end"; const actionVariant: NonNullable = toast.data?.actionVariant ?? "default"; const secondaryActionVariant: NonNullable = @@ -301,7 +302,7 @@ function deriveToastBodyDescriptor(toast: { const hasSecondaryAction = toast.data?.secondaryActionProps !== undefined; const hasTrailingControls = copyErrorText !== null || - toast.actionProps !== undefined || + hasVisibleToastAction(toast.actionProps) || hasAdditionalActions || hasSecondaryAction; const inlineContentEndPad = hasTrailingControls ? "pr-6" : "pr-10"; @@ -400,12 +401,12 @@ function ToastBodyContent({ variant={secondaryActionVariant} /> ) : null} - {actionProps ? ( + {hasVisibleToastAction(actionProps) ? ( - {actionProps.children} + {actionProps?.children} ) : null}
    @@ -798,7 +799,7 @@ function AnchoredToasts() { ); } -export { stackedThreadToast } from "./toastHelpers"; +export { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; export type { StackedThreadToastOptions } from "./toastHelpers"; export { diff --git a/apps/web/src/components/ui/toastHelpers.test.ts b/apps/web/src/components/ui/toastHelpers.test.ts new file mode 100644 index 000000000..c356aef40 --- /dev/null +++ b/apps/web/src/components/ui/toastHelpers.test.ts @@ -0,0 +1,21 @@ +import { assert, describe, it } from "vite-plus/test"; + +import { hiddenToastActionProps, stackedThreadToast } from "./toastHelpers"; + +describe("hiddenToastActionProps", () => { + it("is a defined update payload so Base UI can replace a previous action", () => { + assert.equal(hiddenToastActionProps.children, null); + assert.equal( + "actionProps" in stackedThreadToast({ type: "loading", title: "Updating" }), + false, + ); + assert.deepEqual( + stackedThreadToast({ + type: "loading", + title: "Updating", + actionProps: hiddenToastActionProps, + }).actionProps, + hiddenToastActionProps, + ); + }); +}); diff --git a/apps/web/src/components/ui/toastHelpers.ts b/apps/web/src/components/ui/toastHelpers.ts index 4ec5d1410..70e77ccd3 100644 --- a/apps/web/src/components/ui/toastHelpers.ts +++ b/apps/web/src/components/ui/toastHelpers.ts @@ -17,6 +17,14 @@ export type StackedThreadToastOptions = { data?: Omit; }; +/** + * Defined `actionProps` that hide a previous toast CTA on `toastManager.update`. + * Passing `actionProps: undefined` is a no-op because updates omit undefined keys. + */ +export const hiddenToastActionProps = { + children: null, +} as const satisfies Pick, "children">; + /** * Thread toast using the stacked body + bottom action row (copy for errors, CTA on its own row). */ From 7a243f533c933a6f172c3e5d6376c86476de262a Mon Sep 17 00:00:00 2001 From: Linus Boehm Date: Sat, 15 Aug 2026 14:03:28 +0200 Subject: [PATCH 46/99] fix(desktop): agent shells inherit a UTF-8 locale on macOS (#6236) (cherry picked from commit e204f5a5d34c753399531e772fbb11420caeea09) --- .../src/shell/DesktopShellEnvironment.test.ts | 102 ++++++++++++++++++ .../src/shell/DesktopShellEnvironment.ts | 28 +++++ 2 files changed, 130 insertions(+) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 9b7a9a207..8c145ece7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -162,6 +162,108 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("hydrates the locale from the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LANG: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "de_DE.UTF-8"); + }), + ); + + it.effect("preserves an inherited locale over the login shell on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LANG: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "en_US.UTF-8"); + }), + ); + + it.effect("does not mix login-shell locale categories into an inherited locale", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + LANG: "en_US.UTF-8", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => + envOutput({ + PATH: "/opt/homebrew/bin:/usr/bin", + LC_ALL: "de_DE.UTF-8", + }), + }); + + assert.equal(env.LANG, "en_US.UTF-8"); + assert.equal(env.LC_ALL, undefined); + }), + ); + + it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }), + }); + + assert.equal(env.LANG, undefined); + assert.equal(env.LC_ALL, undefined); + assert.equal(env.LC_CTYPE, "en_US.UTF-8"); + }), + ); + + it.effect("does not apply the locale fallback on linux", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }), + }); + + assert.equal(env.LANG, undefined); + }), + ); + it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 16c1f14ca..f8a84c10f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -71,6 +71,9 @@ const LOGIN_SHELL_ENV_NAMES = [ "PATH", "DBUS_SESSION_BUS_ADDRESS", "DISPLAY", + "LANG", + "LC_ALL", + "LC_CTYPE", "SSH_AUTH_SOCK", "HOMEBREW_PREFIX", "HOMEBREW_CELLAR", @@ -84,6 +87,8 @@ const LOGIN_SHELL_ENV_NAMES = [ "WAYLAND_DISPLAY", ] as const; const WINDOWS_PROFILE_ENV_NAMES = ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"] as const; +const LOCALE_ENV_NAMES = ["LANG", "LC_ALL", "LC_CTYPE"] as const; +const FALLBACK_LC_CTYPE = "en_US.UTF-8"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); @@ -458,6 +463,29 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix } } + // Locale variables form one precedence group: LC_ALL can override an inherited + // LANG or LC_CTYPE, so only hydrate the group when the process has none of them. + if ( + config.platform === "darwin" && + LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name]))) + ) { + for (const name of LOCALE_ENV_NAMES) { + const value = trimNonEmpty(shellEnvironment[name]); + if (Option.isSome(value)) { + config.env[name] = value.value; + } + } + + // GUI launches inherit no locale from launchd, so spawned agents land in the C + // locale and pbcopy decodes their UTF-8 output as MacRoman. Older supported + // macOS releases do not provide C.UTF-8, so set only LC_CTYPE to a UTF-8 locale + // available on those releases. Leaving LANG unset keeps C-stable collation and + // formatting, so output parsing is unaffected. + if (LOCALE_ENV_NAMES.every((name) => Option.isNone(trimNonEmpty(config.env[name])))) { + config.env.LC_CTYPE = FALLBACK_LC_CTYPE; + } + } + if ( config.platform === "linux" && Option.isNone(trimNonEmpty(config.env.DBUS_SESSION_BUS_ADDRESS)) From 4074fcb2a905b388f7f1052cdcb6c9addc0bb1d8 Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 15 Aug 2026 17:03:43 +0500 Subject: [PATCH 47/99] fix(server): ignore Claude command lifecycle messages (#6606) (cherry picked from commit 474cc5fb017a13dbdb403dd3bf714ddbf867205a) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 71 +++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 5 ++ 2 files changed, 76 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 68c8debc4..2bf5d4cbc 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2458,6 +2458,77 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("consumes Claude command lifecycle notifications silently", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const sessionId = "6e81554e-5cff-4b37-8a39-f3a9051ac234"; + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const readyMessage = "command lifecycle test ready"; + const readyFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "runtime.warning" && event.payload.message === readyMessage, + ).pipe(Stream.runDrain, Effect.forkChild); + harness.query.emit({ + type: "system", + subtype: "notification", + key: "command-lifecycle-ready", + text: readyMessage, + priority: "high", + session_id: sessionId, + uuid: "command-lifecycle-ready", + } as unknown as SDKMessage); + yield* Fiber.join(readyFiber); + + const processedMessage = "command lifecycle messages processed"; + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "runtime.warning" && event.payload.message === processedMessage, + ).pipe(Stream.runCollect, Effect.forkChild); + for (const [state, uuid] of [ + ["started", "command-started"], + ["completed", "command-completed"], + ]) { + harness.query.emit({ + type: "command_lifecycle", + command_uuid: "4cd8e8a3-df7a-425d-b6c9-4053abc0b8fd", + state, + session_id: sessionId, + uuid, + } as unknown as SDKMessage); + } + harness.query.emit({ + type: "system", + subtype: "notification", + key: "command-lifecycle-processed", + text: processedMessage, + priority: "high", + session_id: sessionId, + uuid: "command-lifecycle-processed", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + assert.deepEqual( + runtimeEvents.map((event) => event.type), + ["runtime.warning"], + ); + const warning = runtimeEvents[0]; + assert.equal(warning?.type, "runtime.warning"); + if (warning?.type === "runtime.warning") { + assert.equal(warning.payload.message, processedMessage); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("emits thread token usage updates from Claude task progress", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index eafd5a7a4..ac724e5b9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3500,6 +3500,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( yield* logNativeSdkMessage(context, message); yield* ensureThreadId(context, message); + // Wire-only command bookkeeping has no user-facing T3 lifecycle. + if (sdkMessageType(message) === "command_lifecycle") { + return; + } + switch (message.type) { case "stream_event": yield* handleStreamEvent(context, message); From 46e63e00b2d5959aadedb38f7b16c1ff34a5fb01 Mon Sep 17 00:00:00 2001 From: Gerwin <9853101+thamrx@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:04:02 +0200 Subject: [PATCH 48/99] docs: mention Bitbucket user read scope needed by auth probe (#6291) Co-authored-by: Gerwin Bisschop Co-authored-by: Claude Fable 5 (cherry picked from commit 402c9e0748ced0125c1b40793f7d170efb9b6b2a) --- .../server/src/sourceControl/BitbucketSourceControlProvider.ts | 2 +- docs/user/source-control.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 974fbb94a..59fab76e5 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -195,7 +195,7 @@ export const makeDiscovery = Effect.gen(function* () { kind: "bitbucket", label: "Bitbucket", installHint: - "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request and repository scopes).", + "Set T3CODE_BITBUCKET_EMAIL and T3CODE_BITBUCKET_API_TOKEN on the server (use a Bitbucket API token with pull request, repository, and user read scopes).", probeAuth: bitbucket.probeAuth, } satisfies SourceControlApiDiscoverySpec; }); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index a7197753b..c3346983a 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -103,7 +103,8 @@ export T3CODE_BITBUCKET_ACCESS_TOKEN="your-access-token" ``` Or an Atlassian account email plus API token, with read/write access to pull requests and -repositories: +repositories, plus read access to your user account (`read:user:bitbucket`, used to verify the +connection): ```bash export T3CODE_BITBUCKET_EMAIL="you@example.com" From aff4b54bc748052b17af3ec510a99be3d7496b66 Mon Sep 17 00:00:00 2001 From: duncan-vc Date: Sat, 15 Aug 2026 05:04:21 -0700 Subject: [PATCH 49/99] fix(server): return valid preview action results (#5966) Co-authored-by: duncan-vc <247855047+duncan-vc@users.noreply.github.com> (cherry picked from commit 551f4c99c1c42c767daea1f2920cfc8fbc78a1cb) --- apps/server/src/mcp/McpHttpServer.test.ts | 32 +++++++++++++------ .../src/mcp/toolkits/preview/handlers.ts | 11 +++---- .../src/mcp/toolkits/preview/tools.test.ts | 17 ++++++++++ apps/server/src/mcp/toolkits/preview/tools.ts | 14 +++++--- 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index fe902f4e9..fa2880f9c 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -219,6 +219,11 @@ it.effect("registers annotated tools and preserves authenticated request context expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); + expect(clickTool?.tool.outputSchema).toEqual({ + type: "object", + additionalProperties: false, + description: "The preview action completed successfully.", + }); const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); @@ -260,15 +265,24 @@ it.effect("registers annotated tools and preserves authenticated request context alternateTabId, ); - const press = yield* server - .callTool({ name: "preview_press", arguments: { key: "Enter" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(press.isError).toBe(false); - expect(press.structuredContent).toBeNull(); - expect(press.content).toEqual([{ type: "text", text: "null" }]); + const actionRequests = [ + { name: "preview_click", arguments: { x: 10, y: 10 } }, + { name: "preview_type", arguments: { text: "Hello" } }, + { name: "preview_press", arguments: { key: "Enter" } }, + { name: "preview_scroll", arguments: { deltaY: 100 } }, + { name: "preview_wait_for", arguments: { text: "Example" } }, + ]; + for (const request of actionRequests) { + const result = yield* server + .callTool(request) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(false); + expect(result.structuredContent).toEqual({}); + expect(result.content).toEqual([{ type: "text", text: "{}" }]); + } }), ).pipe(Effect.provide(TestLayer)), ); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 1c7ff6f9c..25be8b363 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -72,15 +72,14 @@ const handlers = { invokeTargeted("setColorScheme", input), preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), preview_click: (input) => - invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), - preview_type: (input) => - invokeTargeted("type", input, input.timeoutMs).pipe(Effect.as(null)), - preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as(null)), - preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as(null)), + invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as({})), + preview_type: (input) => invokeTargeted("type", input, input.timeoutMs).pipe(Effect.as({})), + preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as({})), + preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as({})), preview_evaluate: (input) => invokeTargeted("evaluate", input).pipe(Effect.map((result) => result ?? null)), preview_wait_for: (input) => - invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), + invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as({})), preview_recording_start: (input) => invokeTargeted("recordingStart", input ?? {}), preview_recording_stop: (input) => diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 652c20e6a..2cdc67ad7 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -55,3 +55,20 @@ it("exports provider-compatible object schemas with described parameters", () => } } }); + +it("exports exact object result schemas for preview actions", () => { + const actionNames = [ + "preview_click", + "preview_type", + "preview_press", + "preview_scroll", + "preview_wait_for", + ] as const; + for (const name of actionNames) { + expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({ + type: "object", + additionalProperties: false, + description: "The preview action completed successfully.", + }); + } +}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index a94d2b056..3baf56a79 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -29,6 +29,10 @@ const dependencies = [ PreviewAutomationBroker.PreviewAutomationBroker, ]; +const PreviewActionResult = Schema.Record(Schema.String, Schema.Never).annotate({ + description: "The preview action completed successfully.", +}); + const browserTool = (tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; @@ -117,7 +121,7 @@ export const PreviewClickTool = browserTool( description: "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", parameters: PreviewAutomationClickInput, - success: Schema.Null, + success: PreviewActionResult, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Click preview page"), @@ -128,7 +132,7 @@ export const PreviewTypeTool = browserTool( description: "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", parameters: PreviewAutomationTypeInput, - success: Schema.Null, + success: PreviewActionResult, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Type into preview page"), @@ -139,7 +143,7 @@ export const PreviewPressTool = browserTool( description: "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", parameters: PreviewAutomationPressInput, - success: Schema.Null, + success: PreviewActionResult, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Press key in preview page"), @@ -150,7 +154,7 @@ export const PreviewScrollTool = safeBrowserTool( description: "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", parameters: PreviewAutomationScrollInput, - success: Schema.Null, + success: PreviewActionResult, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Scroll preview page"), @@ -172,7 +176,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( description: "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", parameters: PreviewAutomationWaitForInput, - success: Schema.Null, + success: PreviewActionResult, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Wait for preview page condition"), From 94c184ebf704a1b27719746c13751570aee476f4 Mon Sep 17 00:00:00 2001 From: Joaquin Navarro Date: Sat, 15 Aug 2026 08:05:21 -0400 Subject: [PATCH 50/99] fix(claude): make "Always allow for session" stick, and only for the session (#5041) Co-authored-by: Claude Fable 5 (cherry picked from commit e9e46972fbabdee0eb39cbd86470a5b5f5f26910) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 112 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 38 +++++- 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2bf5d4cbc..cf079f24c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3531,6 +3531,118 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("acceptForSession returns session-scoped permission updates", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "approve this for the session", + attachments: [], + }); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + + const createInput = harness.getLastCreateQueryInput(); + const canUseTool = createInput?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const respondToNextRequest = Effect.gen(function* () { + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some" || requested.value.type !== "request.opened") { + return; + } + const runtimeRequestId = requested.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.make(runtimeRequestId), + "acceptForSession", + ); + yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain); + }); + + // MCP tools frequently arrive with no usable suggestion (Claude Code + // sends an empty array); the decision must still stick for the session. + const mcpPermissionPromise = canUseTool( + "mcp__linear__create_issue", + { title: "hello" }, + { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-use-mcp-1", + }, + ); + yield* respondToNextRequest; + const mcpPermission = (yield* Effect.promise(() => mcpPermissionPromise)) as PermissionResult; + assert.equal(mcpPermission.behavior, "allow"); + if (mcpPermission.behavior !== "allow") { + return; + } + assert.deepEqual(mcpPermission.updatedPermissions, [ + { + type: "addRules", + rules: [{ toolName: "mcp__linear__create_issue" }], + behavior: "allow", + destination: "session", + }, + ]); + + // Received suggestions are reused but rescoped to the session — + // echoing "localSettings" would persist a session-only choice to disk. + const bashPermissionPromise = canUseTool( + "Bash", + { command: "git status" }, + { + signal: new AbortController().signal, + suggestions: [ + { + type: "addRules", + rules: [{ toolName: "Bash", ruleContent: "git status" }], + behavior: "allow", + destination: "localSettings", + }, + ], + toolUseID: "tool-use-bash-1", + }, + ); + yield* respondToNextRequest; + const bashPermission = (yield* Effect.promise( + () => bashPermissionPromise, + )) as PermissionResult; + assert.equal(bashPermission.behavior, "allow"); + if (bashPermission.behavior !== "allow") { + return; + } + assert.deepEqual(bashPermission.updatedPermissions, [ + { + type: "addRules", + rules: [{ toolName: "Bash", ruleContent: "git status" }], + behavior: "allow", + destination: "session", + }, + ]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("classifies Agent tools and read-only Claude tools correctly for approvals", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index ac724e5b9..a40c62a08 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -160,6 +160,37 @@ interface PendingApproval { readonly decision: Deferred.Deferred; } +/** + * Permission updates applied for an "Always allow this session" decision. + * + * Claude Code's suggestions are reused when present but rescoped to + * `destination: "session"` — echoing them verbatim would persist the + * session-only choice as a permanent rule (suggestions typically target + * `localSettings`, i.e. `.claude/settings.local.json`). When Claude Code + * offers no suggestion — common for MCP tools — fall back to a whole-tool + * session allow rule so the decision still sticks for the session instead of + * silently degrading into a one-shot accept. + */ +function toSessionPermissionUpdates( + toolName: string, + suggestions: ReadonlyArray | undefined, +): Array { + const sessionScoped = (suggestions ?? []).map( + (suggestion): PermissionUpdate => ({ ...suggestion, destination: "session" }), + ); + if (sessionScoped.length > 0) { + return sessionScoped; + } + return [ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "session", + }, + ]; +} + interface PendingUserInput { readonly questions: ReadonlyArray; readonly answers: Deferred.Deferred; @@ -4049,9 +4080,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return { behavior: "allow", updatedInput: toolInput, - ...(decision === "acceptForSession" && pendingApproval.suggestions + ...(decision === "acceptForSession" ? { - updatedPermissions: [...pendingApproval.suggestions], + updatedPermissions: toSessionPermissionUpdates( + toolName, + pendingApproval.suggestions, + ), } : {}), } satisfies PermissionResult; From b218bc5d3a06b07d5caf8bf64d9e738c01de45f3 Mon Sep 17 00:00:00 2001 From: Torben Wetter Date: Sat, 15 Aug 2026 14:05:45 +0200 Subject: [PATCH 51/99] fix(ssh): surface a failed remote t3 install instead of a silent 0-byte server.log (#5132) (cherry picked from commit 9d0f2fc212858290e79710b44c0635a28dd92c2f) --- packages/ssh/src/tunnel.test.ts | 9 +++++++++ packages/ssh/src/tunnel.ts | 22 +++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index be17b8ffa..461509ea0 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -108,6 +108,9 @@ describe("ssh tunnel scripts", () => { assert.include(script, "exec npx --yes 't3@latest' \"$@\""); assert.include(script, "exec npm exec --yes 't3@latest' -- \"$@\""); assert.include(script, "could not install 't3@latest'"); + assert.include(script, "require_installed_t3_cli npx --yes --package 't3@latest'"); + assert.include(script, "require_installed_t3_cli npm exec --yes --package 't3@latest'"); + assert.include(script, "npm produced no t3 executable"); assert.include(script, 'prepend_path_if_dir "$HOME/.local/bin"'); assert.include(script, `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`); assert.include(script, "remote_node_satisfies_engine()"); @@ -140,6 +143,10 @@ describe("ssh tunnel scripts", () => { assert.include(script, "exec npx --yes 't3@nightly; touch /tmp/t3-owned' \"$@\""); assert.include(script, "exec npm exec --yes 't3@nightly; touch /tmp/t3-owned' -- \"$@\""); + assert.include( + script, + "require_installed_t3_cli npx --yes --package 't3@nightly; touch /tmp/t3-owned'", + ); assert.notInclude(script, "exec npx --yes t3@nightly; touch /tmp/t3-owned"); }); @@ -185,6 +192,8 @@ describe("ssh tunnel scripts", () => { assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); + assert.include(buildRemoteLaunchScript(), 'if [ -s "$LOG_FILE" ]; then'); + assert.include(buildRemoteLaunchScript(), "It wrote nothing to %s"); assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index a1611c577..12ab00278 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -426,10 +426,26 @@ fi if command -v t3 >/dev/null 2>&1; then exec t3 "$@" fi +# npm extracts a package before it runs the native builds of its dependencies, +# so a failed build (t3 depends on node-pty, which needs a C toolchain) leaves +# the npx cache without a t3 executable. \`npx --yes\` then exits 0 without +# running anything at all, which the caller only ever sees as a server that +# never becomes ready. Resolve the CLI once up front so that install failure is +# reported here, with npm's own output on stderr. +require_installed_t3_cli() { + T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if [ -n "$T3_CLI_PATH" ]; then + return 0 + fi + printf 'Remote host installed %s but npm produced no t3 executable, which usually means a native dependency (node-pty) failed to build. Install a C toolchain on the remote host (Debian/Ubuntu: build-essential, Fedora/RHEL: gcc-c++ make, macOS: xcode-select --install) and try again.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 +} if command -v npx >/dev/null 2>&1; then + require_installed_t3_cli npx --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 exec npx --yes @@T3_PACKAGE_SPEC@@ "$@" fi if command -v npm >/dev/null 2>&1; then + require_installed_t3_cli npm exec --yes --package @@T3_PACKAGE_SPEC@@ || exit 1 exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" fi printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because node/npm/npx are unavailable on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2 @@ -581,7 +597,11 @@ if [ -z "$REMOTE_PORT" ]; then printf 'managed\\n' >"$MANAGED_FILE" if ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2 - tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true + if [ -s "$LOG_FILE" ]; then + tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true + else + printf 'It wrote nothing to %s, so it exited before producing any output.\\n' "$LOG_FILE" >&2 + fi kill "$REMOTE_PID" 2>/dev/null || true wait_for_pid_exit "$REMOTE_PID" rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" From 12d3ea717868b46759dc5f7a5810757ea5b788a1 Mon Sep 17 00:00:00 2001 From: Martin Bergo Date: Sat, 15 Aug 2026 14:05:48 +0200 Subject: [PATCH 52/99] perf(server): persist the wire projection for streaming tool.updated data (#6675) Co-authored-by: mInrOz <14320143+mInrOz@users.noreply.github.com> Co-authored-by: Claude Fable 5 (cherry picked from commit f075a58119f392137d699cefe5290b2aa6e55935) --- .../ProviderRuntimeIngestion.activity.test.ts | 61 +++++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 12 +++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts index 4539191b0..e49167888 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.activity.test.ts @@ -594,3 +594,64 @@ describe("runtimeEventToActivities Prime tool lifecycle", () => { expect(fallbackSummaries).toEqual(["Tool started", "Tool updated", "Tool"]); }); }); + +describe("runtimeEventToActivities tool streaming persistence", () => { + const accumulatedStdout = [ + "first line of output", + ...Array.from({ length: 500 }, (_, index) => `Capturing frame ${index}/9028`), + ].join("\n"); + const streamingData = { + toolCallId: "tool-call-1", + kind: "execute", + command: "blender --render", + rawOutput: { stdout: accumulatedStdout }, + content: [{ type: "content", content: { type: "text", text: accumulatedStdout } }], + }; + + it("persists tool.updated with the wire projection of data, not the accumulated stream", () => { + const event = { + ...base, + type: "item.updated", + eventId: EventId.make("evt-tool-streaming-updated"), + payload: { + itemType: "command_execution", + status: "inProgress", + title: "Render", + detail: accumulatedStdout, + data: streamingData, + }, + } satisfies ProviderRuntimeEvent; + + const activities = runtimeEventToActivities(event); + + expect(activities).toHaveLength(1); + const payload = activities[0]?.payload as Record; + const data = payload.data as Record; + expect(payload.status).toBe("inProgress"); + expect(data.toolCallId).toBe("tool-call-1"); + expect(data.command).toBe("blender --render"); + expect(data.rawOutput).toEqual({ content: "first line of output" }); + expect(data.content).toBeUndefined(); + expect(JSON.stringify(data).length).toBeLessThan(1_000); + }); + + it("persists the full terminal payload on tool.completed", () => { + const event = { + ...base, + type: "item.completed", + eventId: EventId.make("evt-tool-streaming-completed"), + payload: { + itemType: "command_execution", + status: "completed", + title: "Render", + data: streamingData, + }, + } satisfies ProviderRuntimeEvent; + + const activities = runtimeEventToActivities(event); + + expect(activities).toHaveLength(1); + const payload = activities[0]?.payload as Record; + expect(payload.data).toEqual(streamingData); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 98435d781..050a2c9a6 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -48,6 +48,7 @@ import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; +import { projectActivityPayload } from "../ActivityPayloadProjection.ts"; import { forkParked } from "../../serverActivation.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { canReplaceThreadTitle } from "../threadTitles.ts"; @@ -1239,8 +1240,15 @@ export function runtimeEventToActivities( return []; } const primeAgentTool = isPrimeAgentToolLifecycle(event); + // A streaming update's `data` carries the full tool output accumulated + // so far (adapters merge state forward), and a new activity is emitted + // per chunk, so persisting `data` verbatim writes O(N²) bytes per tool + // call into both the event store and the projection table. No reader + // needs it: ws.ts and http.ts apply `projectActivityPayload` before any + // payload reaches a client. Persist the projected form for non-terminal + // updates; `item.completed` below still persists the full payload. return [ - { + projectActivityPayload({ id: toolLifecycleActivityId(event), createdAt: event.createdAt, tone: "tool", @@ -1262,7 +1270,7 @@ export function runtimeEventToActivities( }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, - }, + }), ]; } From 028300ccabb14770b0561650b9624438e0566514 Mon Sep 17 00:00:00 2001 From: Tai Nguyen <87302343+JoeJoeflyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:06:22 +0700 Subject: [PATCH 53/99] fix(web): stop wrapping partial code block selections in markdown fences (#5069) (cherry picked from commit c4556ab237515cea8ec6f482351c58da730ad495) --- apps/web/src/markdown-clipboard.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 86965eebf..f56b3a492 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -301,6 +301,17 @@ export function chatMarkdownClipboardPayload( if (range.collapsed) continue; const container = document.createElement("div"); container.appendChild(range.cloneContents()); + const ancestor = range.commonAncestorContainer; + const ancestorElement = + ancestor.nodeType === Node.ELEMENT_NODE ? (ancestor as Element) : ancestor.parentElement; + if (ancestorElement?.closest("pre")) { + const text = range.toString(); + if (text) { + texts.push(text); + htmls.push(sanitizedHtmlFrom(container)); + } + continue; + } const text = serializeRenderedMarkdownFragment(container); if (!text) continue; texts.push(text); From ff5a50c721deef07f888e53e38af59cd8e3e9483 Mon Sep 17 00:00:00 2001 From: Tai Nguyen <87302343+JoeJoeflyn@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:06:25 +0700 Subject: [PATCH 54/99] fix(web): hide T3 Connect toggle in web app settings (#5068) (cherry picked from commit efe1773e9c5222857b88c006d47215b023703a04) --- .../settings/ConnectionsSettings.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index afc510348..5ffff5ff0 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1650,23 +1650,25 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b return ( <> - void updateManagedTunnel(enabled)} - /> - } - /> + {window.desktopBridge ? ( + void updateManagedTunnel(enabled)} + /> + } + /> + ) : null} Date: Sat, 15 Aug 2026 14:06:50 +0200 Subject: [PATCH 55/99] fix(web): show provider account accent badge in sidebar rows and hover card (#5980) Co-authored-by: Claude Fable 5 (cherry picked from commit a5d35321bd620400dc03ddf7a42ee2672b225ac3) --- apps/web/src/components/Sidebar.tsx | 55 +++++++++++++++---- .../components/chat/ModelPickerSidebar.tsx | 19 +++---- .../components/chat/ProviderModelPicker.tsx | 8 +-- apps/web/src/providerInstances.ts | 17 ++++++ 4 files changed, 71 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 530b41c65..5689bb669 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -156,7 +156,11 @@ import { import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; -import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../providerInstances"; +import { + deriveProviderInstanceEntries, + shouldShowInstanceBadge, + type ProviderInstanceEntry, +} from "../providerInstances"; import { primaryServerProvidersAtom } from "../state/server"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; @@ -246,7 +250,8 @@ function SidebarThreadTooltip({ projectCwd, projectFaviconPath, environmentLabel, - driverKind, + providerEntry, + showInstanceBadge, modelInstanceId, modelLabel, branchMismatch, @@ -258,7 +263,8 @@ function SidebarThreadTooltip({ projectCwd: string | null; projectFaviconPath: string | null; environmentLabel: string | null; - driverKind: ProviderInstanceEntry["driverKind"] | null; + providerEntry: ProviderInstanceEntry | null; + showInstanceBadge: boolean; modelInstanceId: string; modelLabel: string; branchMismatch: { @@ -268,6 +274,7 @@ function SidebarThreadTooltip({ terminalStatus: TerminalStatusIndicator | null; terminalProcessCount: number; }) { + const driverKind = providerEntry?.driverKind ?? null; return ( -
    {modelLabel}
    +
    + {showInstanceBadge && providerEntry + ? `${modelLabel} · ${providerEntry.displayName}` + : modelLabel} +
) : null} {terminalStatus ? ( @@ -861,6 +879,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -878,7 +899,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} @@ -1483,11 +1505,19 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} {driverKind ? ( - + ) : null} @@ -1544,7 +1574,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { }); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; + const showInstanceBadge = + providerEntry !== null && + shouldShowInstanceBadge(providerEntry, props.providerEntryByInstanceId.values()); const selectedModel = providerEntry?.models.find( (model) => model.slug === thread.modelSelection.model, ); @@ -1602,7 +1634,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { projectCwd={props.projectCwd} projectFaviconPath={props.projectFaviconPath} environmentLabel={props.environmentLabel} - driverKind={driverKind} + providerEntry={providerEntry} + showInstanceBadge={showInstanceBadge} modelInstanceId={modelInstanceId} modelLabel={modelLabel} branchMismatch={branchMismatch} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 05b44dcb7..df35cbd90 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -1,10 +1,14 @@ import { type ProviderInstanceId } from "@t3tools/contracts"; -import { memo, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, useLayoutEffect, useRef, useState } from "react"; import { SparklesIcon, StarIcon } from "lucide-react"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; -import { isProviderInstancePickerReady, type ProviderInstanceEntry } from "../../providerInstances"; +import { + isProviderInstancePickerReady, + shouldShowInstanceBadge, + type ProviderInstanceEntry, +} from "../../providerInstances"; /** * Build the hover tooltip for an instance button. Mirrors the old @@ -65,14 +69,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const [hoveredInstanceId, setHoveredInstanceId] = useState(null); const sidebarContentRef = useRef(null); const [selectedIndicatorTop, setSelectedIndicatorTop] = useState(null); - const duplicateDriverCounts = useMemo(() => { - const counts = new Map(); - for (const entry of props.instanceEntries) { - counts.set(entry.driverKind, (counts.get(entry.driverKind) ?? 0) + 1); - } - return counts; - }, [props.instanceEntries]); - useLayoutEffect(() => { const content = sidebarContentRef.current; if (!content) { @@ -143,8 +139,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { const isSelected = props.selectedInstanceId === entry.instanceId; const isHovered = hoveredInstanceId === entry.instanceId; const showNewBadge = props.newBadgeInstanceIds?.has(entry.instanceId) ?? false; - const showInstanceBadge = - Boolean(entry.accentColor) || (duplicateDriverCounts.get(entry.driverKind) ?? 0) > 1; + const showInstanceBadge = shouldShowInstanceBadge(entry, props.instanceEntries); const tooltip = isUnavailable ? describeUnavailableInstance(entry) diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index a9b3a3981..bd374a0fd 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -16,7 +16,7 @@ import { getTriggerDisplayModelLabel, getTriggerDisplayModelName, } from "./providerIconUtils"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; import { ComposerControl, ComposerControlChevron } from "./ComposerControl"; export const ProviderModelPicker = memo(function ProviderModelPicker(props: { @@ -67,10 +67,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { selectedInstanceOptions[0]; const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model; const triggerLabel = selectedModel ? getTriggerDisplayModelLabel(selectedModel) : props.model; - const duplicateDriverCount = props.instanceEntries.filter( - (entry) => activeEntry !== null && entry.driverKind === activeEntry.driverKind, - ).length; - const showInstanceBadge = Boolean(activeEntry?.accentColor) || duplicateDriverCount > 1; + const showInstanceBadge = + activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); const setIsMenuOpen = (open: boolean) => { props.onOpenChange?.(open); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 10197c2c0..80eb819ca 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -115,6 +115,23 @@ function driverKindLabel(driverKind: ProviderDriverKind): string { return PROVIDER_DISPLAY_NAMES[driverKind] ?? formatProviderDriverKindLabel(driverKind); } +/** + * Whether an instance's icon carries the account badge: accent color set, or + * several instances sharing a driver so the brand glyph alone is ambiguous. + * Shared by the composer trigger, the picker rail, and sidebar rows. + */ +export function shouldShowInstanceBadge( + entry: ProviderInstanceEntry, + entries: Iterable, +): boolean { + if (entry.accentColor) return true; + let sharedDriverCount = 0; + for (const candidate of entries) { + if (candidate.driverKind === entry.driverKind && ++sharedDriverCount > 1) return true; + } + return false; +} + export function normalizeProviderAccentColor(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed) return undefined; From 0d4f5d89f03a6166c164f001976543e14d55f717 Mon Sep 17 00:00:00 2001 From: Ostap <33957189+ostapondo@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:06:53 +0200 Subject: [PATCH 56/99] fix(server): wait for concurrent SQLite writers instead of failing with SQLITE_BUSY (#5134) (cherry picked from commit c0f9d917c1ab08d30f2b3715dd25d2175a6d2ecf) --- .../src/persistence/Layers/Sqlite.test.ts | 66 +++++++++++++++++++ apps/server/src/persistence/Layers/Sqlite.ts | 2 + 2 files changed, 68 insertions(+) create mode 100644 apps/server/src/persistence/Layers/Sqlite.test.ts diff --git a/apps/server/src/persistence/Layers/Sqlite.test.ts b/apps/server/src/persistence/Layers/Sqlite.test.ts new file mode 100644 index 000000000..0b64e4f7f --- /dev/null +++ b/apps/server/src/persistence/Layers/Sqlite.test.ts @@ -0,0 +1,66 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { SqlitePersistenceMemory, makeSqlitePersistenceLive } from "./Sqlite.ts"; + +const lockHolderSource = ` +const { DatabaseSync } = require("node:sqlite"); +const db = new DatabaseSync(process.argv[1]); +db.exec("BEGIN IMMEDIATE"); +process.stdout.write("locked\\n"); +setTimeout(() => { + db.exec("COMMIT"); + db.close(); +}, Number(process.argv[2])); +`; + +const spawnWriteLockHolder = (dbPath: string, holdMs: number) => + Effect.promise( + () => + new Promise((resolve, reject) => { + const holder = NodeChildProcess.spawn( + process.execPath, + ["-e", lockHolderSource, dbPath, String(holdMs)], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + holder.stdout.once("data", () => resolve()); + holder.on("error", reject); + holder.on("exit", () => + reject(new Error("lock holder exited before acquiring the write lock")), + ); + }), + ); + +it.effect("waits out a concurrent writer instead of failing with SQLITE_BUSY", () => { + const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-busy-")); + const dbPath = NodePath.join(tempDir, "state.sqlite"); + + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE busy_probe(id INTEGER PRIMARY KEY)`; + yield* spawnWriteLockHolder(dbPath, 300); + yield* sql`INSERT INTO busy_probe(id) VALUES (${1})`; + const rows = yield* sql<{ readonly id: number }>`SELECT id FROM busy_probe`; + assert.deepEqual([...rows], [{ id: 1 }]); + }).pipe( + Effect.provide(makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer))), + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))), + ); +}); + +it.effect("applies busy_timeout in the shared persistence setup", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly timeout: number }>`PRAGMA busy_timeout`; + assert.equal(rows[0]?.timeout, 5000); + }).pipe(Effect.provide(SqlitePersistenceMemory)), +); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index d1e002501..ec1ffdefa 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -33,6 +33,8 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( const setup = Layer.effectDiscard( Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // CLI and server write from separate processes; wait rather than fail with SQLITE_BUSY. + yield* sql`PRAGMA busy_timeout = 5000;`; yield* sql`PRAGMA foreign_keys = ON;`; yield* sql`PRAGMA journal_mode = WAL;`; yield* runMigrations(); From 2f5c36818224ef38b3cfda89c1f7fc229fb9b087 Mon Sep 17 00:00:00 2001 From: Naveed Iqbal Date: Sat, 15 Aug 2026 17:07:07 +0500 Subject: [PATCH 57/99] fix(web): reject oversized prompts before provider turn start (#6602) (cherry picked from commit 7c55e86320aac9c68ae53a7bc15682b7e14f98bf) --- apps/web/src/components/ChatView.tsx | 57 +++--- apps/web/src/components/chat/ChatComposer.tsx | 67 ++++++- .../ComposerPromptLengthValidation.test.tsx | 23 +++ .../chat/ComposerPromptLengthValidation.tsx | 13 ++ .../chat/composerSubmission.test.ts | 170 ++++++++++++++++++ .../src/components/chat/composerSubmission.ts | 44 +++++ docs/user/composer.md | 5 + 7 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx create mode 100644 apps/web/src/components/chat/ComposerPromptLengthValidation.tsx create mode 100644 apps/web/src/components/chat/composerSubmission.test.ts create mode 100644 apps/web/src/components/chat/composerSubmission.ts create mode 100644 docs/user/composer.md diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8a735d315..e431e3070 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5314,6 +5314,16 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + const outgoingFollowUpText = formatOutgoingPrompt({ + provider: ctxSelectedProvider, + model: ctxSelectedModel, + models: ctxSelectedProviderModels, + effort: ctxSelectedPromptEffort, + text: followUp.text.trim(), + }); + if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { + return; + } promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5380,24 +5390,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - sendInFlightRef.current = true; - if (isDraftHeroState && activeThreadKey) { - let resolveDockStarted: (() => void) | undefined; - const dockStarted = new Promise((resolve) => { - resolveDockStarted = resolve; - }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); - void dockTransition.catch(() => resolveDockStarted?.()); - await dockStarted; - } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -5415,8 +5407,6 @@ function ChatViewContent(props: ChatViewProps) { messageTextWithPreviewAnnotations, composerReviewCommentsSnapshot, ); - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -5424,6 +5414,30 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { + return; + } + + sendInFlightRef.current = true; + if (isDraftHeroState && activeThreadKey) { + let resolveDockStarted: (() => void) | undefined; + const dockStarted = new Promise((resolve) => { + resolveDockStarted = resolve; + }); + const dockTransition = runMobileComposerTransition(() => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }); + void dockTransition.catch(() => resolveDockStarted?.()); + await dockStarted; + } + beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); + + const messageIdForSend = newMessageId(); + const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -6489,6 +6503,9 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: implementationPrompt, }); + if (composerRef.current?.validateProviderInput(outgoingImplementationPrompt) === false) { + return; + } const nextThreadTitle = truncate(buildPlanImplementationThreadTitle(planMarkdown)); const nextThreadModelSelection: ModelSelection = ctxSelectedModelSelection; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 05f7a105e..768d1b08e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -148,6 +148,12 @@ import { buildExpandedImagePreview, type ExpandedImagePreview } from "./Expanded import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +import { + getComposerPromptLengthValidationMessage, + getComposerSubmissionValidationMessage, + submitComposerDraft, +} from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; type ComposerCommandMenuPosition = { bottom: number; @@ -604,6 +610,8 @@ export interface ChatComposerHandle { selectedModel: string; selectedProviderModels: ReadonlyArray; }; + /** Validate the fully composed text immediately before a provider turn starts. */ + validateProviderInput: (providerInput: string) => boolean; } // -------------------------------------------------------------------------- @@ -1703,6 +1711,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [composerSubmissionError, setComposerSubmissionError] = useState(null); + const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( + null, + ); const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [isStashMenuOpen, setIsStashMenuOpen] = useState(false); const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({ @@ -1719,6 +1731,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerEditorRef = useRef(null); const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); + const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); const composerMenuOpenRef = useRef(false); const composerMenuItemsRef = useRef([]); @@ -2060,6 +2073,27 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor((existing) => clampCollapsedComposerCursor(prompt, existing)); }, [prompt, promptRef]); + useEffect(() => { + if (composerSubmissionError === null) return; + const nextError = getComposerPromptLengthValidationMessage(prompt); + if (nextError !== composerSubmissionError) { + setComposerSubmissionError(nextError); + } + }, [composerSubmissionError, prompt]); + + useEffect(() => { + setProviderInputSubmissionError(null); + }, [ + composerElementContexts, + composerPreviewAnnotations, + composerReviewComments, + composerTerminalContexts, + prompt, + selectedModel, + selectedPromptEffort, + selectedProvider, + ]); + useEffect(() => { composerImagesRef.current = composerImages; }, [composerImages, composerImagesRef]); @@ -2151,6 +2185,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ useEffect(() => { setComposerHighlightedItemId(null); + setComposerSubmissionError(null); + setProviderInputSubmissionError(null); setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length)); setComposerTrigger(detectComposerTrigger(promptRef.current, promptRef.current.length)); setIsDragOverComposer(false); @@ -2577,17 +2613,32 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); return; } - onSend(event); + const submission = submitComposerDraft({ + prompt: promptRef.current, + submissionTarget: activePendingProgress ? "pending-user-input" : "provider-turn", + event, + onSend: (sendEvent) => { + // ChatView reports its final composed-input preflight through the + // composer handle before its first asynchronous send step. + providerInputRejectedRef.current = false; + onSend(sendEvent); + return !providerInputRejectedRef.current; + }, + }); + setComposerSubmissionError(submission.validationMessage); + if (!submission.didDispatch) return; if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, [ activeThreadId, + activePendingProgress, blurMobileComposerAfterSend, isSendDisabled, noProviderAvailable, onSend, + promptRef, shouldBlurMobileComposerOnSubmit, ], ); @@ -3352,6 +3403,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) selectedModel, selectedProviderModels, }), + validateProviderInput: (providerInput: string) => { + const validationMessage = getComposerSubmissionValidationMessage({ + prompt: promptRef.current, + providerInput, + submissionTarget: "provider-turn", + }); + providerInputRejectedRef.current = validationMessage !== null; + setProviderInputSubmissionError(validationMessage); + return validationMessage === null; + }, }), [ activeThread, @@ -3840,6 +3901,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
+ + {/* Bottom toolbar */} {isComposerCollapsedMobile ? null : activePendingApproval ? (
diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx new file mode 100644 index 000000000..3ffb4fa9c --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.test.tsx @@ -0,0 +1,23 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { getComposerPromptLengthValidationMessage } from "./composerSubmission"; +import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; + +describe("ComposerPromptLengthValidation", () => { + it("renders oversized prompt feedback as an actionable composer alert", () => { + const message = getComposerPromptLengthValidationMessage( + "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1), + ); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain('data-chat-composer-validation="prompt-length"'); + expect(markup).toContain( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(markup).not.toContain("ProviderValidationError"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx new file mode 100644 index 000000000..88e4c3b81 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPromptLengthValidation.tsx @@ -0,0 +1,13 @@ +export function ComposerPromptLengthValidation({ message }: { message: string | null }) { + if (!message) return null; + + return ( +

+ {message} +

+ ); +} diff --git a/apps/web/src/components/chat/composerSubmission.test.ts b/apps/web/src/components/chat/composerSubmission.test.ts new file mode 100644 index 000000000..239db28a6 --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.test.ts @@ -0,0 +1,170 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { submitComposerDraft } from "./composerSubmission"; + +describe("submitComposerDraft", () => { + it("keeps an oversized draft editable and sends a corrected follow-up", () => { + let draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + let validationMessage: string | null = null; + const dispatchedDrafts: string[] = []; + const preventDefault = vi.fn(); + + const submit = () => { + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => { + dispatchedDrafts.push(draft); + }, + }); + validationMessage = result.validationMessage; + }; + + submit(); + + expect(dispatchedDrafts).toEqual([]); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + expect(validationMessage).toBe( + "Prompt is 1 character over the 120,000-character limit. Shorten or split it before sending.", + ); + expect(preventDefault).toHaveBeenCalledOnce(); + + draft = "Corrected prompt"; + submit(); + + expect(dispatchedDrafts).toEqual(["Corrected prompt"]); + expect(validationMessage).toBeNull(); + }); + + it("allows a draft at the shared character limit through the normal send path", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("blocks when appended context pushes the provider input over the shared limit", () => { + const draft = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + providerInput: `${draft}\n\nTerminal context`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ + validationMessage: + "Prompt is 18 characters over the 120,000-character limit. Shorten or split it before sending.", + didDispatch: false, + }); + expect(draft).toHaveLength(PROVIDER_SEND_TURN_MAX_INPUT_CHARS); + expect(onSend).not.toHaveBeenCalled(); + + const correctedResult = submitComposerDraft({ + prompt: "Corrected prompt", + providerInput: "Corrected prompt\n\nShort terminal context", + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(correctedResult).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("does not finish submission when the send boundary rejects composed provider input", () => { + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Sendable raw draft", + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend: () => false, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: false }); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + it("allows fully composed provider input at the shared character limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "Short draft", + providerInput: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + }); + + it("blocks a generated plan follow-up that exceeds the shared limit", () => { + const onSend = vi.fn(); + + const result = submitComposerDraft({ + prompt: "", + providerInput: `PLEASE IMPLEMENT THIS PLAN:\n${"x".repeat( + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + )}`, + submissionTarget: "provider-turn", + event: undefined, + onSend, + }); + + expect(result.didDispatch).toBe(false); + expect(result.validationMessage).toContain("over the 120,000-character limit"); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("allows surrounding whitespace that the provider turn contract trims", () => { + const draft = ` ${"x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)} `; + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: draft, + submissionTarget: "provider-turn", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("dispatches pending user input answers on their separate response path", () => { + const answer = "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS + 1); + const onSend = vi.fn(); + const preventDefault = vi.fn(); + + const result = submitComposerDraft({ + prompt: answer, + submissionTarget: "pending-user-input", + event: { preventDefault }, + onSend, + }); + + expect(result).toEqual({ validationMessage: null, didDispatch: true }); + expect(onSend).toHaveBeenCalledOnce(); + expect(preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/chat/composerSubmission.ts b/apps/web/src/components/chat/composerSubmission.ts new file mode 100644 index 000000000..528ac75bc --- /dev/null +++ b/apps/web/src/components/chat/composerSubmission.ts @@ -0,0 +1,44 @@ +import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; + +type ComposerSubmitEvent = { preventDefault: () => void }; + +type ComposerSubmissionInput = { + prompt: string; + providerInput?: string; + submissionTarget: "provider-turn" | "pending-user-input"; +}; + +export function getComposerPromptLengthValidationMessage(prompt: string): string | null { + const excessCharacters = prompt.trim().length - PROVIDER_SEND_TURN_MAX_INPUT_CHARS; + if (excessCharacters <= 0) return null; + + const characterLabel = excessCharacters === 1 ? "character" : "characters"; + return `Prompt is ${excessCharacters.toLocaleString("en-US")} ${characterLabel} over the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS.toLocaleString("en-US")}-character limit. Shorten or split it before sending.`; +} + +export function getComposerSubmissionValidationMessage( + options: ComposerSubmissionInput, +): string | null { + return options.submissionTarget === "provider-turn" + ? getComposerPromptLengthValidationMessage(options.providerInput ?? options.prompt) + : null; +} + +export function submitComposerDraft( + options: ComposerSubmissionInput & { + event: ComposerSubmitEvent | undefined; + onSend: (event?: ComposerSubmitEvent) => boolean | void; + }, +): { validationMessage: string | null; didDispatch: boolean } { + const validationMessage = getComposerSubmissionValidationMessage(options); + if (validationMessage) { + options.event?.preventDefault(); + return { validationMessage, didDispatch: false }; + } + + if (options.onSend(options.event) === false) { + options.event?.preventDefault(); + return { validationMessage: null, didDispatch: false }; + } + return { validationMessage: null, didDispatch: true }; +} diff --git a/docs/user/composer.md b/docs/user/composer.md new file mode 100644 index 000000000..d2e49db24 --- /dev/null +++ b/docs/user/composer.md @@ -0,0 +1,5 @@ +# Message composer + +Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the +composer and shows how many characters need to be removed. Shorten the draft or split it into +multiple messages, then send again in the same thread. From ed0e0672f9543466d8e00cdb25c058b82a20823e Mon Sep 17 00:00:00 2001 From: Jaroslav Brtis <6890442+Jardo-51@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:09 +0200 Subject: [PATCH 58/99] feat(web): collapse the question prompt from its header (#6773) Co-authored-by: Claude Opus 5 (cherry picked from commit 40ab7bf32a81a66b20571ed280dc238c2276dc61) --- .../ComposerPendingUserInputPanel.test.tsx | 61 ++++++ .../chat/ComposerPendingUserInputPanel.tsx | 196 +++++++++++------- 2 files changed, 186 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx new file mode 100644 index 000000000..817182190 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -0,0 +1,61 @@ +import { ApprovalRequestId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; +import type { PendingUserInput } from "../../session-logic"; + +const prompt: PendingUserInput = { + requestId: ApprovalRequestId.make("request-1"), + createdAt: "2026-08-15T00:00:00.000Z", + questions: [ + { + id: "question-1", + header: "Approach", + question: "Which approach should the migration take?", + options: [ + { label: "Incremental", description: "Move one module at a time" }, + { label: "Big bang", description: "Move everything in one release" }, + ], + multiSelect: false, + }, + ], +}; + +function renderPanel() { + return renderToStaticMarkup( + {}} + onAdvance={() => {}} + />, + ); +} + +describe("ComposerPendingUserInputPanel", () => { + it("renders the header as a disclosure control for the question body", () => { + const markup = renderPanel(); + + const toggle = markup.match(/]*data-pending-user-input-toggle="[^"]*"[^>]*>/)?.[0]; + expect(toggle).toBeDefined(); + expect(toggle).toContain('data-pending-user-input-toggle="expanded"'); + expect(toggle).toContain('aria-expanded="true"'); + expect(toggle).toContain('type="button"'); + + const controlledId = toggle?.match(/aria-controls="([^"]+)"/)?.[1]; + expect(controlledId).toBeDefined(); + expect(markup).toMatch(new RegExp(`]*\\sid="${controlledId}"`)); + }); + + it("starts expanded so the question and its options are visible", () => { + const markup = renderPanel(); + + expect(markup).toContain("Approach"); + expect(markup).toContain("Which approach should the migration take?"); + expect(markup).toContain("Incremental"); + expect(markup).toContain("Big bang"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index ceac45c94..75dc5a6f5 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -5,7 +5,8 @@ import { derivePendingUserInputProgress, type PendingUserInputDraftAnswer, } from "../../pendingUserInput"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { @@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( questionId: string; optionLabel: string; } | null>(null); + // Collapsing hides everything but the header so a tall prompt stops covering + // the thread the user is trying to read. Scoped to a single question: the card + // is keyed by request id so the next prompt starts expanded, and storing the + // collapsed question's id (rather than a bare flag) reopens the card when the + // prompt advances to its next question, which can happen without a click — + // sending from the composer advances the active question. + const [collapsedQuestionId, setCollapsedQuestionId] = useState(null); + const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id; useEffect(() => { onAdvanceRef.current = onAdvance; @@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( // Keyboard shortcut: number keys 1-9 select corresponding options when focus is // outside editable fields. Multi-select prompts toggle options in place; single- - // select prompts keep the existing auto-advance behavior. + // select prompts keep the existing auto-advance behavior. Collapsed prompts opt + // out, since the numbers they refer to are not on screen. useEffect(() => { - if (!activeQuestion || isResponding) return; + if (!activeQuestion || isResponding || isCollapsed) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; @@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, isResponding]); + }, [activeQuestion, isCollapsed, isResponding]); if (!activeQuestion) { return null; @@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const customAnswerActive = progress.customAnswer.trim().length > 0; return ( -
-
- - {activeQuestion.header} - - {prompt.questions.length > 1 ? ( - - {questionIndex + 1}/{prompt.questions.length} + { + setCollapsedQuestionId(open ? null : activeQuestion.id); + }} + > + {/* The trigger's wrapper is inset less than the card's text column, and + the trigger pays the difference back as padding: the hover background + and focus ring bleed 10px past that column on both sides, while the + header label and the chevron still line up with the left and right + edges of the question text below. The negative block margin keeps the + taller hit area from pushing the panel down. */} +
+ + + {activeQuestion.header} - ) : null} + {prompt.questions.length > 1 ? ( + + {questionIndex + 1}/{prompt.questions.length} + + ) : null} + {/* Collapsed, the header is otherwise just a section label and a + counter, so the question itself is echoed here as a one-line + reminder of what is being asked. */} + {isCollapsed ? ( + + {activeQuestion.question} + + ) : null} + {/* The chevron points at the body: down while it is open below the + header, up while it is collapsed into it. */} +
-

{activeQuestion.question}

- {activeQuestion.multiSelect ? ( -

Select one or more options.

- ) : null} -
- {activeQuestion.options.map((option, index) => { - const isOptimisticallySelected = - optimisticSingleSelect?.questionId === activeQuestion.id && - optimisticSingleSelect.optionLabel === option.label; - const isSelected = - isOptimisticallySelected || - (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); - const shortcutKey = index < 9 ? index + 1 : null; - const className = cn( - "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", - isSelected - ? "border-primary/30 bg-primary/8 text-foreground" - : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", - isResponding && "opacity-50 cursor-not-allowed", - !isResponding && "cursor-pointer", - ); - const content = ( - <> -
- {option.label} - {option.description && option.description !== option.label ? ( - {option.description} - ) : null} -
- {isSelected ? ( - - ) : shortcutKey !== null ? ( - +
+

{activeQuestion.question}

+ {activeQuestion.multiSelect ? ( +

Select one or more options.

+ ) : null} +
+ {activeQuestion.options.map((option, index) => { + const isOptimisticallySelected = + optimisticSingleSelect?.questionId === activeQuestion.id && + optimisticSingleSelect.optionLabel === option.label; + const isSelected = + isOptimisticallySelected || + (!customAnswerActive && progress.selectedOptionLabels.includes(option.label)); + const shortcutKey = index < 9 ? index + 1 : null; + const className = cn( + "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25", + isSelected + ? "border-primary/30 bg-primary/8 text-foreground" + : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34", + isResponding && "opacity-50 cursor-not-allowed", + !isResponding && "cursor-pointer", + ); + const content = ( + <> +
+ {option.label} + {option.description && option.description !== option.label ? ( + {option.description} + ) : null} +
+ {isSelected ? ( + + ) : shortcutKey !== null ? ( + + {shortcutKey} + + ) : null} + + ); + return ( + - ); - })} -
-
+ {content} + + ); + })} +
+
+ + ); }); From aa830a5c21b1541ebcd53625f318896be32890c1 Mon Sep 17 00:00:00 2001 From: Rishet11 <154429365+Rishet11@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:37:24 +0530 Subject: [PATCH 59/99] fix(shared): degrade an unknown system time zone to UTC in usage windows (#6670) (cherry picked from commit 684d703b0a8a0632a18c8453277f7e5e6312b200) --- packages/shared/src/usageFormat.test.ts | 18 ++++++++++++++++- packages/shared/src/usageFormat.ts | 26 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index cecc07c6e..fb231fbac 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -1,5 +1,5 @@ // @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic. -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { enumerateHourStarts, @@ -54,4 +54,20 @@ describe("hourly usage formatting", () => { expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); }); + + it("degrades an unknown resolved zone to UTC instead of crashing", () => { + const resolved = new Intl.DateTimeFormat().resolvedOptions(); + const resolvedOptions = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockReturnValue({ ...resolved, timeZone: "Etc/Unknown" }); + + try { + const now = new Date("2026-08-11T12:37:42.123Z"); + + expect(makeWindow(1, now, "hour").timeZone).toBe("UTC"); + expect(makeWindow(30, now).timeZone).toBe("UTC"); + } finally { + resolvedOptions.mockRestore(); + } + }); }); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index ef2b2bcf2..bd751829d 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -179,13 +179,25 @@ export function makeWindow( now = new Date(), resolution: UsageResolution = "day", ): UsageSummaryInput { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const format = new Intl.DateTimeFormat("en-CA", { - timeZone, - year: "numeric", - month: "2-digit", - day: "2-digit", - }); + let timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch { + // An unknown zone should degrade to UTC rather than crash the page. + timeZone = "UTC"; + format = new Intl.DateTimeFormat("en-CA", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } const untilDay = format.format(now); if (resolution === "hour") { // Minute-aligned bounds keep labels readable while still representing an From f91fd944c34d67f0ae02debbc30038b85af8d489 Mon Sep 17 00:00:00 2001 From: Roshan Mhatre Date: Sat, 15 Aug 2026 17:37:31 +0530 Subject: [PATCH 60/99] fix(claude): discover repo-local .agents/skills in skill discovery (#5488) (cherry picked from commit ad47d2347c6917f7db33e6e3902e1e8e5d5281ec) --- .../src/provider/Drivers/ClaudeSkills.test.ts | 99 +++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 31 +++--- docs/user/providers-claude.md | 7 ++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 1ad843d75..60db1d0c5 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -66,6 +66,105 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("discovers project skills from the workspace .agents directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "review", + ["---", "name: review", "description: Review the changes.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "review", + path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), + enabled: true, + scope: "project", + description: "Review the changes.", + }, + ]); + }), + ); + + it.effect("prefers workspace .claude skills on three-way name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".claude", "skills"), + "deploy", + ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Claude deploy.", + }, + ]); + }), + ); + + it.effect("prefers workspace .agents skills over user skills on name collisions", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + const workspace = path.join(tempDir, "workspace"); + + yield* writeSkill( + path.join(configDir, "skills"), + "deploy", + ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills"), + "deploy", + ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); + + assert.deepEqual(skills, [ + { + name: "deploy", + path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), + enabled: true, + scope: "project", + description: "Agents deploy.", + }, + ]); + }), + ); + it.effect("prefers project skills over user skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 6dfb5734c..83dbe2112 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -1,12 +1,13 @@ /** * ClaudeSkills — filesystem discovery of Claude Code skills for the `$` picker. * - * Claude Code loads skills from `/skills` (user scope) and - * `/.claude/skills` (project scope), one directory per skill with a - * `SKILL.md` carrying YAML frontmatter. The Agent SDK init handshake surfaces - * skills only as slash commands without their filesystem paths, so the - * provider snapshot scans the same locations directly, mirroring how the - * Codex app-server reports its skills. + * Claude Code loads skills from `/skills` (user scope), then + * `/.agents/skills` and `/.claude/skills` (project scope), one + * directory per skill with a `SKILL.md` carrying YAML frontmatter. Later roots + * win on name collisions, so precedence is user, `.agents`, then `.claude`. + * The Agent SDK init handshake surfaces skills only as slash commands without + * their filesystem paths, so the provider snapshot scans the same locations + * directly, mirroring how the Codex app-server reports its skills. * * @module provider/Drivers/ClaudeSkills */ @@ -84,11 +85,12 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct }); /** - * Enumerate Claude Code skills from the user config dir and the workspace. - * Discovery is best-effort: unreadable roots and malformed skill entries are - * skipped so a broken skill never degrades the provider snapshot. On name - * collisions the project-scoped skill wins, matching Claude Code's - * most-specific-wins resolution. + * Enumerate Claude Code skills from the user config dir, workspace + * `.agents/skills`, and workspace `.claude/skills`, in that order. Discovery + * is best-effort: unreadable roots and malformed skill entries are skipped so + * a broken skill never degrades the provider snapshot. On name collisions, + * later roots win: `.agents` beats user and `.claude` beats `.agents`, matching + * Claude Code's resolution. */ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* ( config: Pick, @@ -101,7 +103,12 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const roots: ReadonlyArray<{ directory: string; scope: ClaudeSkillScope }> = [ { directory: path.join(configDirPath, "skills"), scope: "user" }, - ...(cwd ? [{ directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }] : []), + ...(cwd + ? [ + { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, + ] + : []), ]; const skillsByName = new Map(); diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index 955b5e5a7..12906804a 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -34,6 +34,13 @@ When you set this field, Pylon points Claude Code at that directory with the `CLAUDE_CONFIG_DIR` environment variable. It does not change `HOME`, so your system keychain and the rest of your environment stay as they are. +## Where Claude Skills Are Loaded + +T3 Code looks for Claude skills in the Claude config directory's `skills` folder, then +`/.agents/skills`, then `/.claude/skills`. + +If the same skill name exists in more than one folder, the later folder wins. + ## I Want Work And Personal Claude Accounts Use a different Claude config directory for each account. From 9341c8e268517692178b6c82faae0a2001b3b356 Mon Sep 17 00:00:00 2001 From: Carlos Jimenez Date: Sat, 15 Aug 2026 05:07:38 -0700 Subject: [PATCH 61/99] fix(server): let slow provider CLIs raise their discovery probe budget (#6223) Co-authored-by: Julius Marminge (cherry picked from commit d715c2e56bb718d2225cc0f07cc65e6c637dc229) --- .../AzureDevOpsSourceControlProvider.ts | 4 ++++ .../SourceControlProviderDiscovery.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index bf2ac9829..2f147452f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -45,6 +45,10 @@ export const discovery = { executable: "az", versionArgs: ["--version"], authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + // `az` boots a fresh Python interpreter on every invocation, so even `az --version` + // takes ~6s on Windows and overruns the default budget, leaving the provider reported + // as missing on machines where it is installed. `gh` and `glab` answer in ~0.3s. + probeTimeoutMs: 20_000, parseAuth: parseAzureAuth, installHint: "Install the Azure command-line tools (`az`), then enable Azure DevOps support with `az extension add --name azure-devops`.", diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb..b2b9e4513 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -33,6 +33,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + readonly probeTimeoutMs?: number; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, @@ -52,6 +53,14 @@ type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { readonly refineUnknownRemote: NonNullable; }; +// Most provider CLIs answer `--version` in well under a second, so a short budget keeps +// discovery snappy. Specs whose CLI is known to be slower can raise it via probeTimeoutMs. +const DEFAULT_PROBE_TIMEOUT_MS = 5_000; + +function probeTimeoutMs(spec: SourceControlCliDiscoverySpec): number { + return spec.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; +} + interface DiscoveryProbeResult { readonly kind: SourceControlProviderKind; readonly label: string; @@ -167,7 +176,7 @@ function probeCli(input: { command: input.spec.executable, args: input.spec.versionArgs, cwd: input.cwd, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(input.spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -244,7 +253,7 @@ export function probeSourceControlProvider(input: { args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) @@ -287,7 +296,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide args: spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, - timeoutMs: 5_000, + timeoutMs: probeTimeoutMs(spec), maxOutputBytes: 8_000, appendTruncationMarker: true, }) From 44c2542e69aa3fb145091e36910d486a8b3e3dcf Mon Sep 17 00:00:00 2001 From: sebbonit <36650750+sebbonit@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:07:51 +0200 Subject: [PATCH 62/99] fix(web): retain terminal PR badges after checkout switch (#4755) Co-authored-by: Julius Marminge Co-authored-by: codex (cherry picked from commit d5465aebf2746b8d5f327be3b2424d9412a29075) --- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/components/Sidebar.tsx | 81 ++-- .../components/ThreadStatusIndicators.test.ts | 388 +++++++++++++++++- .../src/components/ThreadStatusIndicators.tsx | 176 +++++++- 4 files changed, 619 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e431e3070..cda165a19 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -312,7 +312,10 @@ import { shouldShowThreadErrorBanner, ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; -import { resolveThreadPr } from "./ThreadStatusIndicators"; +import { + resolveDisplayedThreadPr, + threadChangeRequestSnapshotsAtom, +} from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; import { @@ -1697,6 +1700,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThreadEnvironmentId, activeThreadId], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -4347,9 +4351,11 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); - const activeThreadPr = resolveThreadPr({ + const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, + snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, + retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, }); // The right panel offers the thread's own change request, so it can only offer it once the // branch has one; until then the picker says so rather than opening an empty panel. @@ -4431,6 +4437,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadShell, autoSettleAfterDays, autoSettleOnMerge, + changeRequestSnapshotByKey, nowMinute, supportsSettlement, ]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5689bb669..a42d85614 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -141,10 +141,15 @@ import { import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { ThreadWorktreeIndicator, + nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveThreadPr, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, + setThreadChangeRequestSnapshot, settledPrHoverColorClass, terminalStatusFromRunningIds, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, type TerminalStatusIndicator, } from "./ThreadStatusIndicators"; import { @@ -728,11 +733,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; + changeRequestSnapshot: ThreadChangeRequestSnapshot | null; + onChangeRequestSnapshot: ( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, + ) => void; }) { const { isRenaming, - onChangeRequestState, + changeRequestSnapshot, + onChangeRequestSnapshot, onCancelRename, onCommitRename, onContextMenu, @@ -777,9 +787,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }) : null, ); - const pr = resolveThreadPr({ + const retainTerminalOnBranchMismatch = thread.worktreePath === null; + const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, }); const prState = pr?.state ?? null; @@ -868,13 +881,31 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { activeThreadBranch: thread.branch, currentGitBranch: gitStatus.data?.refName ?? null, }); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prProvider = resolveDisplayedThreadPrProvider({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + const prStatus = prStatusIndicator(pr, prProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; - // Report the PR state so the parent can apply the configured merge rule - // and the always-on close rule during partitioning. useEffect(() => { - onChangeRequestState(threadKey, prState); - }, [onChangeRequestState, prState, threadKey]); + const nextSnapshot = nextThreadChangeRequestSnapshot({ + threadBranch: thread.branch, + gitStatus: gitStatus.data, + snapshot: changeRequestSnapshot, + retainTerminalOnBranchMismatch, + }); + if (nextSnapshot === undefined) return; + onChangeRequestSnapshot(threadKey, nextSnapshot); + }, [ + changeRequestSnapshot, + gitStatus.data, + onChangeRequestSnapshot, + retainTerminalOnBranchMismatch, + thread.branch, + threadKey, + ]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1860,26 +1891,7 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - // PR states stream in per-row. The next partition applies the configured - // merge rule and the always-on close rule. - const [changeRequestStateByKey, setChangeRequestStateByKey] = useState< - ReadonlyMap - >(() => new Map()); - const handleChangeRequestState = useCallback( - (threadKey: string, state: "open" | "closed" | "merged" | null) => { - setChangeRequestStateByKey((current) => { - if ((current.get(threadKey) ?? null) === state) return current; - const next = new Map(current); - if (state === null) { - next.delete(threadKey); - } else { - next.set(threadKey, state); - } - return next; - }); - }, - [], - ); + const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. @@ -1995,7 +2007,11 @@ export default function Sidebar() { const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + const snapshot = changeRequestSnapshotByKey.get(threadKey); + const changeRequestState = + snapshot != null && (thread.worktreePath === null || snapshot.branch === thread.branch) + ? snapshot.pr.state + : null; // Snooze outranks everything, including a pin: "hide until Tuesday" // temporarily suspends "keep on top". The pin survives underneath — // and so does its pinOrderKey, so on wake the thread reappears at @@ -2053,7 +2069,7 @@ export default function Sidebar() { }, [ autoSettleAfterDays, autoSettleOnMerge, - changeRequestStateByKey, + changeRequestSnapshotByKey, nowMinute, scopedProjectKeys, serverConfigs, @@ -3688,7 +3704,8 @@ export default function Sidebar() { onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} - onChangeRequestState={handleChangeRequestState} + changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} + onChangeRequestSnapshot={setThreadChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 3eb8e4f71..f77959d9f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,10 +1,19 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; +import type { OrchestrationThreadShell } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadId, type VcsStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { AtomRegistry } from "effect/unstable/reactivity"; import { + nextThreadChangeRequestSnapshot, prStatusIndicator, + resolveDisplayedThreadPr, + resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsAtom, + type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; function status(overrides: Partial = {}): VcsStatusResult { @@ -30,6 +39,25 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } +function mergedFeaturePr(): NonNullable { + return { + number: 42, + title: "Feature PR", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRef: "main", + headRef: "feature/current", + state: "merged", + }; +} + +function snapshotFor( + branch: string, + pr: NonNullable, + sourceControlProvider?: VcsStatusResult["sourceControlProvider"], +): ThreadChangeRequestSnapshot { + return { branch, pr, sourceControlProvider }; +} + describe("resolveThreadPr", () => { it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { expect( @@ -70,6 +98,362 @@ describe("resolveThreadPr", () => { }); }); +describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { + const featureBranch = "feature/current"; + const mergedPr = mergedFeaturePr(); + const provider = { + kind: "github" as const, + name: "GitHub", + baseUrl: "https://github.com", + }; + + it("returns the live merged PR when the checkout matches the feature branch", () => { + const gitStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBe(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("after caching a merged PR, resolves main status back to the cached feature PR", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); + + const mainStatus = status({ + refName: "main", + isDefaultRef: true, + pr: { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "main", + headRef: "main", + state: "open", + }, + sourceControlProvider: provider, + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(provider); + }); + + it("never attaches a PR reported by main to the feature thread", () => { + const mainPr = { + number: 99, + title: "Unrelated main PR", + url: "https://github.com/pingdotgg/t3code/pull/99", + baseRef: "develop", + headRef: "main", + state: "merged" as const, + }; + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: mainPr }), + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("does not show a cached open PR across a branch mismatch", () => { + const openSnapshot = snapshotFor(featureBranch, { + ...mergedPr, + state: "open", + title: "Still open", + }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("retains a cached closed PR across a branch mismatch", () => { + const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; + const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: closedSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(closedPr); + }); + + it("does not retain or display a terminal PR when a worktree switches branches", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + const mismatchedStatus = status({ refName: "feature/other", pr: null }); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeUndefined(); + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: mismatchedStatus, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: false, + }), + ).toBeNull(); + }); + + it("retains a local terminal snapshot when thread metadata follows the new branch", () => { + const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); + + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: otherBranchSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("clears an open snapshot when a local thread moves to a branch with no PR", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: "main", + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears an open snapshot when a local checkout moves to a different branch", () => { + const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: status({ refName: "main", pr: null }), + snapshot: openSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + }); + + it("clears a retained snapshot when the thread branch is cleared", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPr({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeNull(); + expect( + resolveDisplayedThreadPrProvider({ + threadBranch: null, + gitStatus: status({ refName: "main", pr: null }), + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + }); + + it("does not erase a terminal snapshot when VCS data is missing", () => { + const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); + + expect( + nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toBeUndefined(); + expect( + resolveDisplayedThreadPr({ + threadBranch: featureBranch, + gitStatus: null, + snapshot: terminalSnapshot, + retainTerminalOnBranchMismatch: true, + }), + ).toEqual(mergedPr); + }); + + it("keeps effectiveSettled true for a retained merged PR after a main checkout", () => { + const matchingStatus = status({ + refName: featureBranch, + pr: mergedPr, + sourceControlProvider: provider, + }); + const cached = nextThreadChangeRequestSnapshot({ + threadBranch: featureBranch, + gitStatus: matchingStatus, + snapshot: undefined, + retainTerminalOnBranchMismatch: true, + }); + expect(cached).not.toBeNull(); + expect(cached).not.toBeUndefined(); + + const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); + const displayed = resolveDisplayedThreadPr({ + threadBranch: "main", + gitStatus: mainStatus, + snapshot: cached as ThreadChangeRequestSnapshot, + retainTerminalOnBranchMismatch: true, + }); + expect(displayed?.state).toBe("merged"); + + const shell = { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Feature thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + latestTurn: null, + session: null, + createdAt: "2026-04-09T00:00:00.000Z", + updatedAt: "2026-04-09T00:00:00.000Z", + archivedAt: null, + settledAt: null, + settledOverride: null, + latestUserMessageAt: "2026-04-09T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as OrchestrationThreadShell; + + expect( + effectiveSettled(shell, { + now: "2026-04-10T00:00:00.000Z", + autoSettleAfterDays: null, + changeRequestState: displayed?.state ?? null, + }), + ).toBe(true); + }); +}); + +describe("threadChangeRequestSnapshotsAtom", () => { + it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => + Effect.gen(function* () { + const registry = AtomRegistry.make(); + const threadKey = "environment-1:thread-1"; + const snapshot = snapshotFor("feature/current", mergedFeaturePr()); + + const unmount = registry.mount(threadChangeRequestSnapshotsAtom); + registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); + unmount(); + + yield* Effect.yieldNow; + + const remount = registry.mount(threadChangeRequestSnapshotsAtom); + expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); + + remount(); + registry.dispose(); + }), + ); +}); + describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 2908fdcad..4321f1111 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -4,8 +4,10 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import type { VcsStatusResult } from "@t3tools/contracts"; -import { CloudIcon, FolderGit2Icon, GitPullRequestIcon } from "lucide-react"; +import { Atom } from "effect/unstable/reactivity"; +import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -126,6 +128,178 @@ export function resolveThreadPr(input: { return gitStatus.pr ?? null; } +/** + * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement + * partitions move them, so terminal PR metadata must live above the row. + */ +export interface ThreadChangeRequestSnapshot { + readonly branch: string; + readonly pr: NonNullable; + readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; +} + +export const threadChangeRequestSnapshotsAtom = Atom.make< + ReadonlyMap +>(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); + +function isTerminalChangeRequestState( + state: NonNullable["state"], +): state is "merged" | "closed" { + return state === "merged" || state === "closed"; +} + +function sourceControlProvidersEqual( + left: VcsStatusResult["sourceControlProvider"] | undefined, + right: VcsStatusResult["sourceControlProvider"] | undefined, +): boolean { + if (left === right) return true; + if (left == null || right == null) return left == null && right == null; + return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; +} + +export function threadChangeRequestSnapshotsEqual( + left: ThreadChangeRequestSnapshot, + right: ThreadChangeRequestSnapshot, +): boolean { + return ( + left.branch === right.branch && + left.pr.number === right.pr.number && + left.pr.title === right.pr.title && + left.pr.url === right.pr.url && + left.pr.baseRef === right.pr.baseRef && + left.pr.headRef === right.pr.headRef && + left.pr.state === right.pr.state && + sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) + ); +} + +export function setThreadChangeRequestSnapshot( + threadKey: string, + snapshot: ThreadChangeRequestSnapshot | null, +): void { + appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { + const existing = current.get(threadKey); + if (snapshot === null) { + if (existing === undefined) return [false, current]; + const next = new Map(current); + next.delete(threadKey); + return [true, next]; + } + if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { + return [false, current]; + } + const next = new Map(current); + next.set(threadKey, snapshot); + return [true, next]; + }); +} + +/** + * Authoritative snapshot update from live VCS status. + * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone + * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear + * - snapshot: matching branch reports a PR — store/replace + */ +export function nextThreadChangeRequestSnapshot(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadChangeRequestSnapshot | null | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if (gitStatus === null) { + return undefined; + } + if (threadBranch === null) { + return null; + } + if (gitStatus.refName !== threadBranch) { + return retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ? undefined + : null; + } + if (gitStatus.pr == null) { + if ( + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return undefined; + } + return null; + } + return { + branch: threadBranch, + pr: gitStatus.pr, + sourceControlProvider: gitStatus.sourceControlProvider, + }; +} + +/** + * Live PR when the checkout matches the thread branch; otherwise, for local + * checkouts only, a cached merged/closed PR for the thread. Local thread + * metadata follows the shared checkout, so the cached branch intentionally + * survives that metadata changing to the newly checked-out branch. Open PRs + * are never retained — their state can still change. + */ +export function resolveDisplayedThreadPr(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): ThreadPr | null { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.pr; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.pr; + } + + return null; +} + +export function resolveDisplayedThreadPrProvider(input: { + threadBranch: string | null; + gitStatus: VcsStatusResult | null; + snapshot: ThreadChangeRequestSnapshot | null | undefined; + retainTerminalOnBranchMismatch: boolean; +}): VcsStatusResult["sourceControlProvider"] | undefined { + const { threadBranch, gitStatus, snapshot, retainTerminalOnBranchMismatch } = input; + if ( + threadBranch !== null && + gitStatus !== null && + gitStatus.refName === threadBranch && + gitStatus.pr != null + ) { + return gitStatus.sourceControlProvider; + } + + if ( + threadBranch !== null && + retainTerminalOnBranchMismatch && + snapshot != null && + isTerminalChangeRequestState(snapshot.pr.state) + ) { + return snapshot.sourceControlProvider; + } + + return undefined; +} + export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { From b40775fd11a469598d299df8d149950fc46f804d Mon Sep 17 00:00:00 2001 From: nqrwhal <81386789+nqrwhal@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:07:53 -0700 Subject: [PATCH 63/99] fix(web): show selected model in context window tooltip (#4772) Co-authored-by: Julius Marminge Co-authored-by: codex (cherry picked from commit ca37b19cf8d3882f0b4eee1b9e49050494f30422) --- apps/web/src/components/chat/ChatComposer.tsx | 26 ++++----- .../chat/ContextWindowMeter.logic.test.ts | 58 +++++++++++++++++++ .../chat/ContextWindowMeter.logic.ts | 25 ++++++++ .../components/chat/ContextWindowMeter.tsx | 7 ++- 4 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.test.ts create mode 100644 apps/web/src/components/chat/ContextWindowMeter.logic.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 768d1b08e..680c918ff 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -144,6 +144,7 @@ import { import { ContextWindowMeter } from "./ContextWindowMeter"; import { SessionGoalControl } from "./SessionGoalControl"; import { SessionInputQueueControl } from "./SessionInputQueueControl"; +import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; @@ -300,7 +301,7 @@ import { sessionHarnessRefinementScopeKey as buildSessionHarnessRefinementScopeKey, SESSION_HARNESS_REFINEMENT_CONFIRMATION, } from "../../sessionHarnessRefinement"; -import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../providerModels"; +import { getProviderInteractionModeToggle } from "../../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -495,7 +496,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(props: { compact: boolean; activeContextWindow: ReturnType; - activeThreadProviderDisplayName: string | null; + activeThreadModelDisplayName: string | null; activeProviderUsageAccounts: readonly ProviderUsageAccount[]; timestampFormat: UnifiedSettings["timestampFormat"]; contextCompaction: import("./ContextWindowMeter").ContextCompactionControlProps | null; @@ -531,7 +532,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( {props.activeContextWindow || props.contextCompaction || props.harnessRefinement ? ( deriveLatestContextWindowSnapshot(activeThreadActivities ?? []), [activeThreadActivities], ); - // The running session's instance is the source of truth for both the popover's provider - // name and its usage limits, so the two can never describe different providers. + // The running session's instance is the source of truth for the popover's usage + // limits, so the accounts listed can never describe a different provider. The + // meter's own label names the selected model rather than the provider. const activeThreadProviderInstanceId = activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; - const activeThreadProviderDisplayName = useMemo(() => { - if (!activeThreadProviderInstanceId) return null; - const entry = providerStatuses.find((p) => p.instanceId === activeThreadProviderInstanceId); - if (entry) { - return getProviderDisplayName(providerStatuses, entry.driver); - } - return formatProviderDisplayName(activeThreadProviderInstanceId); - }, [providerStatuses, activeThreadProviderInstanceId]); + const activeThreadModelDisplayName = useMemo( + () => resolveContextWindowModelDisplayName(activeThreadModelSelection, modelOptionsByInstance), + [activeThreadModelSelection, modelOptionsByInstance], + ); // Every configured account for the active thread's driver, not just the one // the thread is bound to: Pylon routes threads across several accounts of the // same provider, so remaining capacity is a question about all of them. @@ -4180,7 +4178,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + it("uses the selected model from the exact provider instance", () => { + const primaryInstanceId = ProviderInstanceId.make("codex"); + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + const modelOptionsByInstance = new Map([ + [ + primaryInstanceId, + [{ slug: "gpt-5.6-sol", name: "Primary profile model", shortName: "Primary" }], + ], + [selectedInstanceId, [{ slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", shortName: "5.6 Sol" }]], + ]); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "gpt-5.6-sol", + }, + modelOptionsByInstance, + ), + ).toBe("5.6 Sol"); + }); + + it("falls back to the selected model slug when model metadata is unavailable", () => { + const selectedInstanceId = ProviderInstanceId.make("codex-work"); + + expect( + resolveContextWindowModelDisplayName( + { + instanceId: selectedInstanceId, + model: "custom-model", + }, + new Map(), + ), + ).toBe("custom-model"); + }); +}); + +describe("formatContextWindowCompactionMessage", () => { + it("describes compaction in terms of the selected model", () => { + expect(formatContextWindowCompactionMessage("GPT-5.6 Sol")).toBe( + "Context for GPT-5.6 Sol compacts automatically when needed.", + ); + }); + + it("uses neutral copy when the model is unavailable", () => { + expect(formatContextWindowCompactionMessage(null)).toBe( + "Context compacts automatically when needed.", + ); + }); +}); diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts new file mode 100644 index 000000000..c87170ffe --- /dev/null +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -0,0 +1,25 @@ +import type { ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; + +export function resolveContextWindowModelDisplayName( + selection: ModelSelection | null | undefined, + modelOptionsByInstance: ReadonlyMap>, +): string | null { + if (!selection) { + return null; + } + + const selectedModel = modelOptionsByInstance + .get(selection.instanceId) + ?.find((model) => model.slug === selection.model); + + return selectedModel ? getTriggerDisplayModelName(selectedModel) : selection.model; +} + +export function formatContextWindowCompactionMessage( + modelDisplayName: string | null | undefined, +): string { + return modelDisplayName + ? `Context for ${modelDisplayName} compacts automatically when needed.` + : "Context compacts automatically when needed."; +} diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index cbb4f0eb8..27258a2ab 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -5,6 +5,7 @@ import type { TimestampFormat } from "@t3tools/contracts/settings"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { Button } from "../ui/button"; import { Switch } from "../ui/switch"; +import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic"; function formatPercentage(value: number | null): string | null { if (value === null || !Number.isFinite(value)) { @@ -136,12 +137,12 @@ export function ContextCompactionControls(props: { export function ContextWindowMeter(props: { usage: ContextWindowSnapshot | null; - providerDisplayName?: string | null; + modelDisplayName?: string | null; timestampFormat: TimestampFormat; compaction?: ContextCompactionControlProps | null; harnessRefinement?: HarnessRefinementControlProps | null; }) { - const { usage, providerDisplayName } = props; + const { usage, modelDisplayName } = props; const usedPercentage = formatPercentage(usage?.usedPercentage ?? null); const normalizedPercentage = Math.max(0, Math.min(100, usage?.usedPercentage ?? 0)); const radius = 9.75; @@ -260,7 +261,7 @@ export function ContextWindowMeter(props: { ) : null} {usage?.compactsAutomatically && !props.compaction ? (
- {providerDisplayName ?? "It"} automatically compacts its context when needed. + {formatContextWindowCompactionMessage(modelDisplayName)}
) : null} {props.compaction ? : null} From 684d105f11c7955241fcc7514af1d82c85d3f8c5 Mon Sep 17 00:00:00 2001 From: CursedApple <36764254+Serendeep@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:08:10 +0200 Subject: [PATCH 64/99] fix(web): scale command details with code font (#6510) (cherry picked from commit 5e147371527154be385f28e57339a71521e528c6) --- .github/pr-assets/6424-after.svg | 1 + .github/pr-assets/6424-before.svg | 1 + apps/web/src/components/chat/MessagesTimeline.test.tsx | 8 +++++++- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++--- 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .github/pr-assets/6424-after.svg create mode 100644 .github/pr-assets/6424-before.svg diff --git a/.github/pr-assets/6424-after.svg b/.github/pr-assets/6424-after.svg new file mode 100644 index 000000000..dbeb594a0 --- /dev/null +++ b/.github/pr-assets/6424-after.svg @@ -0,0 +1 @@ + diff --git a/.github/pr-assets/6424-before.svg b/.github/pr-assets/6424-before.svg new file mode 100644 index 000000000..6b365bad6 --- /dev/null +++ b/.github/pr-assets/6424-before.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 63c389e3c..a142fbd69 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -134,6 +134,7 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let toolCallExpandedBodyClassName: typeof import("./MessagesTimeline").toolCallExpandedBodyClassName; beforeAll(async () => { const classList = { @@ -167,7 +168,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, toolCallExpandedBodyClassName } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -226,6 +227,11 @@ function buildUserTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("sizes expanded tool details with the configured code font size", () => { + expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); + expect(toolCallExpandedBodyClassName).not.toContain("text-[11px]"); + }); + it("uses the larger leading inset only when the top fade is enabled", () => { const timelineEntries = [buildUserTimelineEntry("Hello")]; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6ff6450e6..c4dc26cc3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2091,6 +2091,9 @@ function buildToolCallExpandedBody( return blocks.length > 0 ? blocks.join("\n\n") : null; } +export const toolCallExpandedBodyClassName = + "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text"; + function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( workEntry.sourceActivityKind === "user-input.requested" || @@ -2399,9 +2402,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { onClick={stopRowToggle} onPointerDown={stopRowToggle} > -
-            {expandedBody}
-          
+
{expandedBody}
) : null}
From 7ff4f8536f512e733b507f6d653f8b5be12760af Mon Sep 17 00:00:00 2001 From: John Surles Date: Sat, 15 Aug 2026 08:08:29 -0400 Subject: [PATCH 65/99] fix(web): preserve XML-like tags in user messages (#4133) Co-authored-by: codex Co-authored-by: Julius Marminge (cherry picked from commit cf7bfd1c93974428262ab1419d11c972d01d65fa) --- apps/web/src/components/ChatMarkdown.tsx | 14 +- .../components/chat/MessagesTimeline.test.tsx | 148 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 4 + 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ec88bc912..c4548540e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -117,6 +117,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Parse sanitized raw HTML instead of displaying its source text. */ + parseRawHtml?: boolean; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1360,6 +1362,7 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + parseRawHtml = true, }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1622,7 +1625,7 @@ function ChatMarkdown({ /> ); }, - a({ node, href, children, ...props }) { + a({ node, href, children, title: _title, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; if (!fileLinkMeta) { @@ -1707,6 +1710,9 @@ function ChatMarkdown({ props.className, ); }, + img({ node: _node, title: _title, ...props }) { + return ; + }, code({ node, children, className, ...props }) { if (node?.properties?.dataInlineCode != null) { const codeText = nodeToPlainText(children); @@ -1777,6 +1783,9 @@ function ChatMarkdown({ ]); /* eslint-enable react/no-unstable-nested-components */ + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. + // Keep that behavior explicit because literal mode depends on escaping the + // complete source token instead of dropping it from the rendered message. return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a142fbd69..1efa9c86b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -226,6 +226,17 @@ function buildUserTimelineEntry(text: string) { }; } +function buildAssistantTimelineEntry(text: string) { + const entry = buildUserTimelineEntry(text); + return { + ...entry, + message: { + ...entry.message, + role: "assistant" as const, + }, + }; +} + describe("MessagesTimeline", () => { it("sizes expanded tool details with the configured code font size", () => { expect(toolCallExpandedBodyClassName).toContain("var(--font-size-code"); @@ -485,7 +496,142 @@ describe("MessagesTimeline", () => { expect(markup).toContain("rounded-2xl bg-message p-3"); }); - it("renders inline terminal labels with the composer chip UI", () => { + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + 'Before inside after', + " in your context?", + "Comparison: 2 < 3 and 5 > 4.", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain("<global-agent-instructions scope="workspace">"); + expect(markup).toContain( + "Before <nested data-value="a&b">inside</nested> after", + ); + expect(markup).toContain("</global-agent-instructions> in your context?"); + expect(markup).toContain("Comparison: 2 < 3 and 5 > 4."); + }); + + it("preserves XML-like source inside user code spans and fences", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + `', + "", + "```xml", + '', + "```", + ].join("\n"), + ), + ]} + />, + ); + + expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain("<root><child enabled="true" /></root>"); + }); + + it("does not render markdown title attributes in user messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('href="https://example.com"'); + expect(markup).toContain('src="https://example.com/image.png"'); + expect(markup).not.toContain('title="link tip"'); + expect(markup).not.toContain('title="image tip"'); + }); + + it("renders unsafe user HTML as inert source text", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + globalThis.__t3Xss = 1', + ), + ]} + />, + ); + + expect(markup).toContain("<script>globalThis.__t3Xss = 1</script>"); + expect(markup).toContain( + "<img src="x" onerror="globalThis.__t3Xss = 2">", + ); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toMatch(/)/i); + }); + + it("continues to render sanitized raw HTML in assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + MoreDetails"), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("More"); + expect(markup).not.toContain("<details>"); + }); + + it("sanitizes executable HTML while preserving supported assistant markup", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Safe details", + "", + '', + 'Unsafe link', + "", + ].join(""), + ), + ]} + />, + ); + + expect(markup).toContain('data-markdown-details=""'); + expect(markup).toContain("Safe details"); + expect(markup).not.toMatch(/)/i); + expect(markup).not.toContain("onclick="); + expect(markup).not.toContain("onerror="); + expect(markup).not.toContain("javascript:"); + expect(markup).not.toContain("globalThis.__t3Xss"); + }); + + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( ) : null} {trailingWhitespace ? : null} @@ -1746,6 +1747,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />
) : null @@ -1834,6 +1836,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} />, ); } else if (inlinePrefix.length === 0) { @@ -1859,6 +1862,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { skills={props.skills} className="text-message-foreground" lineBreaks + parseRawHtml={false} /> ); }); From 7e1f689909e1454d1319b1af7b2903c9544e6d85 Mon Sep 17 00:00:00 2001 From: Akos Balogh Date: Sat, 15 Aug 2026 14:10:21 +0200 Subject: [PATCH 66/99] fix(desktop): route mouse thumb buttons to the in-app browser (#4459) Co-authored-by: Claude Opus 4.8 (cherry picked from commit 7c8848ebb054c1f4c1279f634633cddc37cb1fac) --- apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 63 +++++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 19 +++++++ apps/desktop/src/preview/PickPreload.ts | 35 +++++++++++++ 4 files changed, 118 insertions(+) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 00616c6a4..e63597b71 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -4,3 +4,4 @@ export const ELEMENT_PICKED_CHANNEL = "preview:element-picked"; export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; +export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 7b0af84ac..2b4359bd3 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2239,6 +2239,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("navigates the guest history when the thumb-button ipc fires", () => + withManager((manager) => + Effect.gen(function* () { + let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined; + const goBack = vi.fn(); + const goForward = vi.fn(); + let canGoBack = true; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { + on: vi.fn((channel: string, listener: typeof mouseNavigate) => { + if (channel === "preview:mouse-navigate") mouseNavigate = listener; + }), + off: vi.fn(), + }, + send: webviewSend, + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + }, + } as never); + + yield* manager.createTab("tab_nav"); + yield* manager.registerWebview("tab_nav", 42); + expect(mouseNavigate).toBeDefined(); + + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + + mouseNavigate?.({}, { direction: "forward" }); + yield* Effect.yieldNow; + expect(goForward).toHaveBeenCalledOnce(); + + // Ignores unknown payloads and never navigates when history is exhausted. + mouseNavigate?.({}, { direction: "sideways" }); + canGoBack = false; + mouseNavigate?.({}, { direction: "back" }); + yield* Effect.yieldNow; + expect(goBack).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("reveals only files inside the configured browser artifact directory", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index d48b13037..e5a08e7da 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -58,6 +58,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; @@ -1506,6 +1507,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; + const mouseNavigate = (_event: unknown, payload?: unknown): void => { + const direction = + typeof payload === "object" && payload !== null && "direction" in payload + ? (payload as { direction?: unknown }).direction + : undefined; + if (direction !== "back" && direction !== "forward") return; + runFork( + attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { + if (direction === "back") { + if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); + } else if (wc.navigationHistory.canGoForward()) { + wc.navigationHistory.goForward(); + } + }).pipe(Effect.ignore), + ); + }; const forwardShortcut = Effect.fn("PreviewManager.forwardShortcut")(function* ( event: Electron.Event, input: Electron.Input, @@ -1552,6 +1569,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("did-fail-load", failed as never); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -1565,6 +1583,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("did-stop-loading", sync); wc.on("did-fail-load", failed as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); + wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.setWindowOpenHandler(({ url }) => { runFork( attemptPromise({ operation: "openPreviewWindow", tabId, webContentsId: wc.id }, () => diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index d03673400..f315bdcec 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -22,6 +22,7 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, + MOUSE_NAVIGATE_CHANNEL, START_PICK_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; @@ -102,6 +103,40 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Mouse thumb buttons: `button === 3` is Back, `button === 4` is Forward. +const MOUSE_BUTTON_BACK = 3; +const MOUSE_BUTTON_FORWARD = 4; + +const navigationDirectionForButton = (button: number): "back" | "forward" | null => { + if (button === MOUSE_BUTTON_BACK) return "back"; + if (button === MOUSE_BUTTON_FORWARD) return "forward"; + return null; +}; + +// Chromium routes thumb-button history navigation to the *focused* WebContents, +// so hovering this guest without focusing it sends the host app's router back +// instead of the preview. Suppress Chromium's default here and drive this tab's +// history explicitly so the buttons always navigate the browser the pointer is +// over — never the host app. +const suppressNavigationButton = (event: MouseEvent): void => { + if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); +}; + +const requestNavigationForButton = (event: MouseEvent): void => { + if (!event.isTrusted) return; + const direction = navigationDirectionForButton(event.button); + if (direction === null) return; + event.preventDefault(); + event.stopImmediatePropagation(); + ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); +}; + +window.addEventListener("mousedown", suppressNavigationButton, true); +window.addEventListener("mouseup", requestNavigationForButton, true); +window.addEventListener("auxclick", suppressNavigationButton, true); + const nextId = (prefix: string): string => { idSequence += 1; return `${prefix}_${idSequence.toString(36)}`; From 0fce713e99f246769533effa7b56b7718b876f0a Mon Sep 17 00:00:00 2001 From: jorvarea <47249803+jorvarea@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:10:32 +0200 Subject: [PATCH 67/99] fix(web): keep the final segment of directory paths with a trailing separator (#5460) Co-authored-by: jorvarea (cherry picked from commit f915320914d1bc446e60cbcfe4cd7d75bad4dc2a) --- apps/web/src/markdown-links.test.ts | 25 +++++++++++++++++++++++++ apps/web/src/markdown-links.ts | 8 ++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc296138..f7c507c17 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -273,3 +273,28 @@ describe("resolveInlineCodeFileLinkMeta", () => { expect(resolveInlineCodeFileLinkMeta(".plans/worktree-management-v1.md")).toBeNull(); }); }); + +describe("directory paths with a trailing separator", () => { + it("keeps the final segment for a POSIX directory path", () => { + expect(resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project")).toMatchObject({ + basename: "favicons", + }); + }); + + it("keeps the final segment for a Windows directory path", () => { + expect( + resolveMarkdownFileLinkMeta("C:\\Users\\kelchm\\.claude\\", "/repo/project"), + ).toMatchObject({ basename: ".claude" }); + }); + + it("matches the label of the same path without a trailing separator", () => { + const withSlash = resolveMarkdownFileLinkMeta("/tmp/favicons/", "/repo/project"); + const withoutSlash = resolveMarkdownFileLinkMeta("/tmp/favicons", "/repo/project"); + expect(withSlash?.basename).toBe(withoutSlash?.basename); + }); + + it("does not produce an empty label for the filesystem root", () => { + const meta = resolveMarkdownFileLinkMeta("/tmp/", "/repo/project"); + expect(meta?.basename).not.toBe(""); + }); +}); diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b..e74bd1701 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -359,8 +359,12 @@ export function resolveInlineCodeFileLinkMeta( } function basenameOfPath(path: string): string { - const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; + // A trailing separator is a valid way to write a directory, so trim it before + // taking the final segment. Without this the segment reads as empty and the + // chip renders with no label at all. + const trimmed = path.replace(/[/\\]+$/, "") || path; + const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed; } function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { From 412e8033b41f16c635f493c61d593ed2767a86cd Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:42:15 +0530 Subject: [PATCH 68/99] Keep block code plain when copying from rendered markdown (#4468) (cherry picked from commit 7083bce26aa89fedfc482ad44cf61c5508a58db7) --- apps/web/src/markdown-clipboard.test.ts | 95 +++++++++++++++++++++++++ apps/web/src/markdown-clipboard.ts | 22 +++++- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/markdown-clipboard.test.ts diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts new file mode 100644 index 000000000..7265e8b60 --- /dev/null +++ b/apps/web/src/markdown-clipboard.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { serializeRenderedMarkdownFragment } from "./markdown-clipboard"; + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +class FakeText { + readonly nodeType = TEXT_NODE; + readonly childNodes: ReadonlyArray = []; + + constructor(readonly textContent: string) {} +} + +class FakeElement { + readonly nodeType = ELEMENT_NODE; + readonly childNodes: Array = []; + readonly classList = { + contains: (name: string) => this.classNames.includes(name), + }; + + constructor( + readonly tagName: string, + private readonly classNames: ReadonlyArray = [], + ) {} + + get localName(): string { + return this.tagName.toLowerCase(); + } + + get textContent(): string { + return this.childNodes.map((child) => child.textContent).join(""); + } + + append(...children: Array): this { + this.childNodes.push(...children); + return this; + } + + getAttribute(): string | null { + return null; + } + + hasAttribute(): boolean { + return false; + } +} + +function asNode(element: FakeElement): Node { + return element as unknown as Node; +} + +function shikiCodeLine(text: string): FakeElement { + const token = new FakeElement("SPAN").append(new FakeText(text)); + return new FakeElement("SPAN", ["line"]).append(token); +} + +describe("serializeRenderedMarkdownFragment", () => { + beforeEach(() => { + vi.stubGlobal("Node", { TEXT_NODE, ELEMENT_NODE }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("wraps inline code in backticks", () => { + const paragraph = new FakeElement("P").append( + new FakeText("run "), + new FakeElement("CODE").append(new FakeText("git status")), + new FakeText(" first"), + ); + const container = new FakeElement("DIV").append(paragraph); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); + }); + + it("keeps a highlighted block code selection plain when its pre wrapper is outside the range", () => { + const code = new FakeElement("CODE").append( + shikiCodeLine("git show-ref --verify refs/remotes/origin/opt/deploy/dev"), + ); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + "git show-ref --verify refs/remotes/origin/opt/deploy/dev", + ); + }); + + it("keeps a multi-line code selection plain instead of inline-wrapping it", () => { + const code = new FakeElement("CODE").append(new FakeText("first line\nsecond line")); + const container = new FakeElement("DIV").append(code); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("first line\nsecond line"); + }); +}); diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index f56b3a492..069d161a1 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -37,6 +37,22 @@ function wrapInlineMarker(content: string, marker: string): string { return `${match?.[1] ?? ""}${marker}${core}${marker}${match?.[3] ?? ""}`; } +/** + * A code element whose pre wrapper fell outside the copied range is still + * block code, recognizable by its highlighter line spans or embedded + * newlines. Wrapping it like inline code produces backtick-surrounded + * shell commands on paste. + */ +function isBlockCodeElement(element: Element, content: string): boolean { + if (content.includes("\n")) return true; + for (const child of element.childNodes) { + if (child.nodeType === Node.ELEMENT_NODE && (child as Element).classList.contains("line")) { + return true; + } + } + return false; +} + function wrapInlineCode(code: string): string { const longestRun = [...(code.match(/`+/g) ?? [])].reduce( (max, run) => Math.max(max, run.length), @@ -201,8 +217,10 @@ function serializeNode(node: Node): string { return `${serializeChildren(element).trim()}\n\n`; case "PRE": return serializeCodeBlock(element); - case "CODE": - return wrapInlineCode(element.textContent ?? ""); + case "CODE": { + const content = element.textContent ?? ""; + return isBlockCodeElement(element, content) ? content : wrapInlineCode(content); + } case "STRONG": case "B": return wrapInlineMarker(serializeChildren(element), "**"); From 289438fe82317c7ba71b6b57fbc46eb0223dbdfb Mon Sep 17 00:00:00 2001 From: Alex Brodsky <122503996+Albro3459@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:12:42 -0500 Subject: [PATCH 69/99] fix(web): add web app manifest so installed app keeps its scope (#4306) (cherry picked from commit 21b6fb528d6b2d3b3e333b2bd4455d6cdf7d7a41) --- apps/web/index.html | 1 + apps/web/public/manifest.webmanifest | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 apps/web/public/manifest.webmanifest diff --git a/apps/web/index.html b/apps/web/index.html index 681f81ca3..06c8b5e1d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ +